Merge branch 'develop' into renovate/lifecycle
This commit is contained in:
commit
0ca2bd0585
58 changed files with 746 additions and 322 deletions
|
|
@ -27,10 +27,18 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold
|
|||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) : MatrixClientProvider {
|
||||
class MatrixClientsHolder @Inject constructor(
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
) : MatrixClientProvider {
|
||||
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
|
||||
private val restoreMutex = Mutex()
|
||||
|
||||
init {
|
||||
authenticationService.listenToNewMatrixClients { matrixClient ->
|
||||
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
sessionIdsToMatrixClient.clear()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,4 +81,17 @@ class MatrixClientsHolderTest {
|
|||
matrixClientsHolder.restoreWithSavedState(savedStateMap)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
|
||||
fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID))
|
||||
val loginSucceeded = fakeAuthenticationService.login("user", "pass")
|
||||
|
||||
assertThat(loginSucceeded.isSuccess).isTrue()
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNotNull()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import androidx.core.content.IntentCompat
|
|||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.CallType.ExternalUrl
|
||||
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
||||
import io.element.android.features.call.impl.di.CallBindings
|
||||
import io.element.android.features.call.impl.pip.PictureInPictureEvents
|
||||
|
|
@ -44,11 +45,14 @@ import io.element.android.features.call.impl.pip.PipView
|
|||
import io.element.android.features.call.impl.services.CallForegroundService
|
||||
import io.element.android.features.call.impl.utils.CallIntentDataParser
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
private val loggerTag = LoggerTag("ElementCallActivity")
|
||||
|
||||
class ElementCallActivity :
|
||||
AppCompatActivity(),
|
||||
CallScreenNavigator,
|
||||
|
|
@ -132,7 +136,7 @@ class ElementCallActivity :
|
|||
DisposableEffect(Unit) {
|
||||
val listener = Runnable {
|
||||
if (requestPermissionCallback != null) {
|
||||
Timber.w("Ignoring onUserLeaveHint event because user is asked to grant permissions")
|
||||
Timber.tag(loggerTag.value).w("Ignoring onUserLeaveHint event because user is asked to grant permissions")
|
||||
} else {
|
||||
pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
|
||||
}
|
||||
|
|
@ -146,7 +150,7 @@ class ElementCallActivity :
|
|||
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
|
||||
pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
|
||||
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||
Timber.d("Exiting PiP mode: Hangup the call")
|
||||
Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
|
||||
eventSink?.invoke(CallScreenEvents.Hangup)
|
||||
}
|
||||
}
|
||||
|
|
@ -185,23 +189,23 @@ class ElementCallActivity :
|
|||
|
||||
private fun setCallType(intent: Intent?) {
|
||||
val callType = intent?.let {
|
||||
IntentCompat.getParcelableExtra(it, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
|
||||
IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
|
||||
?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl)
|
||||
}
|
||||
val intentUrl = intent?.dataString?.let(::parseUrl)
|
||||
when {
|
||||
// Re-opened the activity but we have no url to load or a cached one, finish the activity
|
||||
intent?.dataString == null && callType == null && webViewTarget.value == null -> finish()
|
||||
callType != null -> {
|
||||
webViewTarget.value = callType
|
||||
presenter = presenterFactory.create(callType, this)
|
||||
}
|
||||
intentUrl != null -> {
|
||||
val fallbackInputs = CallType.ExternalUrl(intentUrl)
|
||||
webViewTarget.value = fallbackInputs
|
||||
presenter = presenterFactory.create(fallbackInputs, this)
|
||||
}
|
||||
// Coming back from notification, do nothing
|
||||
else -> return
|
||||
val currentCallType = webViewTarget.value
|
||||
if (currentCallType == null && callType == null) {
|
||||
Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity")
|
||||
finish()
|
||||
} else if (currentCallType == null) {
|
||||
Timber.tag(loggerTag.value).d("Set the call type and create the presenter")
|
||||
webViewTarget.value = callType
|
||||
presenter = presenterFactory.create(callType!!, this)
|
||||
} else if (callType != currentCallType) {
|
||||
Timber.tag(loggerTag.value).d("User starts another call, restart the Activity")
|
||||
setIntent(intent)
|
||||
recreate()
|
||||
} else {
|
||||
Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -175,7 +175,12 @@ class ConfigureRoomPresenter @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun uploadAvatar(avatarUri: Uri): String {
|
||||
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
|
||||
val preprocessed = mediaPreProcessor.process(
|
||||
uri = avatarUri,
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
deleteOriginal = false,
|
||||
compressIfPossible = false,
|
||||
).getOrThrow()
|
||||
val byteArray = preprocessed.file.readBytes()
|
||||
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ dependencies {
|
|||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.analytics.noop)
|
||||
testImplementation(projects.libraries.permissions.impl)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@
|
|||
package io.element.android.features.ftue.impl
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
|
|
@ -31,12 +34,14 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
|||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -80,14 +85,17 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
super.onBuilt()
|
||||
|
||||
lifecycle.subscribe(onCreate = {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
moveToNextStepIfNeeded()
|
||||
})
|
||||
|
||||
analyticsService.didAskUserConsent()
|
||||
.distinctUntilChanged()
|
||||
.onEach {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
}
|
||||
.onEach { moveToNextStepIfNeeded() }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
ftueState.isVerificationStatusKnown
|
||||
.filter { it }
|
||||
.onEach { moveToNextStepIfNeeded() }
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +107,7 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
NavTarget.SessionVerification -> {
|
||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||
override fun onDone() {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
moveToNextStepIfNeeded()
|
||||
}
|
||||
}
|
||||
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
|
||||
|
|
@ -107,7 +115,7 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
NavTarget.NotificationsOptIn -> {
|
||||
val callback = object : NotificationsOptInNode.Callback {
|
||||
override fun onNotificationsOptInFinished() {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
moveToNextStepIfNeeded()
|
||||
}
|
||||
}
|
||||
createNode<NotificationsOptInNode>(buildContext, listOf(callback))
|
||||
|
|
@ -118,7 +126,7 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
NavTarget.LockScreenSetup -> {
|
||||
val callback = object : LockScreenEntryPoint.Callback {
|
||||
override fun onSetupDone() {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
moveToNextStepIfNeeded()
|
||||
}
|
||||
}
|
||||
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
|
||||
|
|
@ -128,8 +136,11 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun moveToNextStep() = lifecycleScope.launch {
|
||||
private fun moveToNextStepIfNeeded() = lifecycleScope.launch {
|
||||
when (ftueState.getNextStep()) {
|
||||
FtueStep.WaitingForInitialState -> {
|
||||
backstack.newRoot(NavTarget.Placeholder)
|
||||
}
|
||||
FtueStep.SessionVerification -> {
|
||||
backstack.newRoot(NavTarget.SessionVerification)
|
||||
}
|
||||
|
|
@ -155,7 +166,14 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
class PlaceholderNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<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>() {
|
||||
|
|
|
|||
|
|
@ -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.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.timeout
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@SingleIn(SessionScope::class)
|
||||
|
|
@ -50,6 +46,14 @@ class DefaultFtueService @Inject constructor(
|
|||
) : FtueService {
|
||||
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() {
|
||||
analyticsService.reset()
|
||||
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||
|
|
@ -70,7 +74,12 @@ class DefaultFtueService @Inject constructor(
|
|||
|
||||
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
|
||||
when (currentStep) {
|
||||
null -> if (isSessionNotVerified()) {
|
||||
null -> if (!isSessionVerificationStateReady()) {
|
||||
FtueStep.WaitingForInitialState
|
||||
} else {
|
||||
getNextStep(FtueStep.WaitingForInitialState)
|
||||
}
|
||||
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) {
|
||||
FtueStep.SessionVerification
|
||||
} else {
|
||||
getNextStep(FtueStep.SessionVerification)
|
||||
|
|
@ -90,34 +99,18 @@ class DefaultFtueService @Inject constructor(
|
|||
} else {
|
||||
getNextStep(FtueStep.AnalyticsOptIn)
|
||||
}
|
||||
FtueStep.AnalyticsOptIn -> {
|
||||
updateState()
|
||||
null
|
||||
}
|
||||
FtueStep.AnalyticsOptIn -> null
|
||||
}
|
||||
|
||||
private suspend fun isAnyStepIncomplete(): Boolean {
|
||||
return listOf<suspend () -> Boolean>(
|
||||
{ isSessionNotVerified() },
|
||||
{ shouldAskNotificationPermissions() },
|
||||
{ needsAnalyticsOptIn() },
|
||||
{ shouldDisplayLockscreenSetup() },
|
||||
).any { it() }
|
||||
private fun isSessionVerificationStateReady(): Boolean {
|
||||
return sessionVerificationService.sessionVerifiedStatus.value != SessionVerifiedStatus.Unknown
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private suspend fun isSessionNotVerified(): Boolean {
|
||||
// Wait for the first known (or ready) verification status
|
||||
val readyVerifiedSessionStatus = sessionVerificationService.sessionVerifiedStatus
|
||||
.filter { it != SessionVerifiedStatus.Unknown }
|
||||
// This is not ideal, but there are some very rare cases when reading the flow seems to get stuck
|
||||
.timeout(5.seconds)
|
||||
.catch {
|
||||
Timber.e(it, "Failed to get session verification status, assume it's not verified")
|
||||
emit(SessionVerifiedStatus.NotVerified)
|
||||
}
|
||||
.first()
|
||||
return readyVerifiedSessionStatus == SessionVerifiedStatus.NotVerified && !canSkipVerification()
|
||||
// Wait until the session verification status is known
|
||||
isVerificationStatusKnown.filter { it }.first()
|
||||
|
||||
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
|
||||
}
|
||||
|
||||
private suspend fun canSkipVerification(): Boolean {
|
||||
|
|
@ -145,14 +138,17 @@ class DefaultFtueService @Inject constructor(
|
|||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal suspend fun updateState() {
|
||||
val nextStep = getNextStep()
|
||||
state.value = when {
|
||||
isAnyStepIncomplete() -> FtueState.Incomplete
|
||||
else -> FtueState.Complete
|
||||
// Final state, there aren't any more next steps
|
||||
nextStep == null -> FtueState.Complete
|
||||
else -> FtueState.Incomplete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface FtueStep {
|
||||
data object WaitingForInitialState : FtueStep
|
||||
data object SessionVerification : FtueStep
|
||||
data object NotificationsOptIn : FtueStep
|
||||
data object AnalyticsOptIn : FtueStep
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
|
|||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.noop.NoopAnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
|
|
@ -73,6 +74,27 @@ class DefaultFtueServiceTest {
|
|||
assertThat(service.state.value).isEqualTo(FtueState.Complete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given all checks being true with no analytics, FtueState is Complete`() = runTest {
|
||||
val analyticsService = NoopAnalyticsService()
|
||||
val sessionVerificationService = FakeSessionVerificationService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
|
||||
val lockScreenService = FakeLockScreenService()
|
||||
val service = createDefaultFtueService(
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
analyticsService = analyticsService,
|
||||
permissionStateProvider = permissionStateProvider,
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
service.updateState()
|
||||
|
||||
assertThat(service.state.value).isEqualTo(FtueState.Complete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `traverse flow`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
|
|
|
|||
|
|
@ -12,5 +12,6 @@ import androidx.compose.runtime.Immutable
|
|||
@Immutable
|
||||
sealed interface AttachmentsPreviewEvents {
|
||||
data object SendAttachment : AttachmentsPreviewEvents
|
||||
data object Cancel : AttachmentsPreviewEvents
|
||||
data object ClearSendState : AttachmentsPreviewEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,14 @@ class AttachmentsPreviewNode @AssistedInject constructor(
|
|||
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
private val presenter = presenterFactory.create(inputs.attachment)
|
||||
private val onDoneListener = OnDoneListener {
|
||||
navigateUp()
|
||||
}
|
||||
|
||||
private val presenter = presenterFactory.create(
|
||||
attachment = inputs.attachment,
|
||||
onDoneListener = onDoneListener,
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
|
|
@ -39,7 +46,6 @@ class AttachmentsPreviewNode @AssistedInject constructor(
|
|||
val state = presenter.present()
|
||||
AttachmentsPreviewView(
|
||||
state = state,
|
||||
onDismiss = this::navigateUp,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
|
|
@ -34,12 +35,17 @@ import kotlin.coroutines.coroutineContext
|
|||
|
||||
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
@Assisted private val attachment: Attachment,
|
||||
@Assisted private val onDoneListener: OnDoneListener,
|
||||
private val mediaSender: MediaSender,
|
||||
private val permalinkBuilder: PermalinkBuilder,
|
||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||
) : Presenter<AttachmentsPreviewState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(attachment: Attachment): AttachmentsPreviewPresenter
|
||||
fun create(
|
||||
attachment: Attachment,
|
||||
onDoneListener: OnDoneListener,
|
||||
): AttachmentsPreviewPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -68,6 +74,9 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
sendActionState = sendActionState,
|
||||
)
|
||||
}
|
||||
AttachmentsPreviewEvents.Cancel -> {
|
||||
coroutineScope.cancel(attachment)
|
||||
}
|
||||
AttachmentsPreviewEvents.ClearSendState -> {
|
||||
ongoingSendAttachmentJob.value?.let {
|
||||
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(
|
||||
mediaAttachment: Attachment.Media,
|
||||
caption: String?,
|
||||
|
|
@ -124,7 +145,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
).getOrThrow()
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
sendActionState.value = SendActionState.Done
|
||||
onDoneListener()
|
||||
},
|
||||
onFailure = { error ->
|
||||
Timber.e(error, "Failed to send attachment")
|
||||
|
|
|
|||
|
|
@ -36,5 +36,4 @@ sealed interface SendActionState {
|
|||
}
|
||||
|
||||
data class Failure(val error: Throwable) : SendActionState
|
||||
data object Done : SendActionState
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl.attachments.preview
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.material3.ExperimentalMaterial3Api
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -50,22 +48,22 @@ import me.saket.telephoto.zoomable.rememberZoomableState
|
|||
@Composable
|
||||
fun AttachmentsPreviewView(
|
||||
state: AttachmentsPreviewState,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun postSendAttachment() {
|
||||
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||
}
|
||||
|
||||
fun postCancel() {
|
||||
state.eventSink(AttachmentsPreviewEvents.Cancel)
|
||||
}
|
||||
|
||||
fun postClearSendState() {
|
||||
state.eventSink(AttachmentsPreviewEvents.ClearSendState)
|
||||
}
|
||||
|
||||
if (state.sendActionState is SendActionState.Done) {
|
||||
val latestOnDismiss by rememberUpdatedState(onDismiss)
|
||||
LaunchedEffect(state.sendActionState) {
|
||||
latestOnDismiss()
|
||||
}
|
||||
BackHandler(enabled = state.sendActionState !is SendActionState.Sending) {
|
||||
postCancel()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -75,7 +73,7 @@ fun AttachmentsPreviewView(
|
|||
navigationIcon = {
|
||||
BackButton(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
onClick = onDismiss,
|
||||
onClick = ::postCancel,
|
||||
)
|
||||
},
|
||||
title = {},
|
||||
|
|
@ -202,6 +200,5 @@ private fun AttachmentsPreviewBottomActions(
|
|||
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
|
||||
AttachmentsPreviewView(
|
||||
state = state,
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -51,7 +51,6 @@ import io.element.android.libraries.designsystem.components.blurhash.blurHashBac
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
|
|
@ -86,13 +85,7 @@ fun TimelineItemImageView(
|
|||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
||||
model = MediaRequestData(
|
||||
source = content.preferredMediaSource,
|
||||
kind = MediaRequestData.Kind.File(
|
||||
fileName = content.filename,
|
||||
mimeType = content.mimeType,
|
||||
),
|
||||
),
|
||||
model = content.thumbnailMediaRequestData,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
|
||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
|
@ -97,9 +99,9 @@ fun TimelineItemVideoView(
|
|||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
||||
model = MediaRequestData(
|
||||
source = content.thumbnailSource,
|
||||
kind = MediaRequestData.Kind.File(
|
||||
fileName = content.filename,
|
||||
mimeType = content.mimeType
|
||||
kind = MediaRequestData.Kind.Thumbnail(
|
||||
width = content.thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH,
|
||||
height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT,
|
||||
)
|
||||
),
|
||||
contentScale = ContentScale.Fit,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
blurhash = messageType.info?.blurhash,
|
||||
width = messageType.info?.width?.toInt(),
|
||||
height = messageType.info?.height?.toInt(),
|
||||
thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(),
|
||||
thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(),
|
||||
aspectRatio = aspectRatio,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
|
||||
|
|
@ -146,6 +148,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
width = messageType.info?.width?.toInt(),
|
||||
height = messageType.info?.height?.toInt(),
|
||||
thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(),
|
||||
thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(),
|
||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||
blurHash = messageType.info?.blurhash,
|
||||
aspectRatio = aspectRatio,
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
|
||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
|
||||
data class TimelineItemImageContent(
|
||||
override val filename: String,
|
||||
|
|
@ -23,15 +26,31 @@ data class TimelineItemImageContent(
|
|||
val blurhash: String?,
|
||||
val width: Int?,
|
||||
val height: Int?,
|
||||
val thumbnailWidth: Int?,
|
||||
val thumbnailHeight: Int?,
|
||||
val aspectRatio: Float?
|
||||
) : TimelineItemEventContentWithAttachment {
|
||||
override val type: String = "TimelineItemImageContent"
|
||||
|
||||
val showCaption = caption != null
|
||||
|
||||
val preferredMediaSource = if (mimeType == MimeTypes.Gif) {
|
||||
mediaSource
|
||||
} else {
|
||||
thumbnailSource ?: mediaSource
|
||||
val thumbnailMediaRequestData: MediaRequestData by lazy {
|
||||
if (mimeType.isMimeTypeAnimatedImage()) {
|
||||
MediaRequestData(
|
||||
source = mediaSource,
|
||||
kind = MediaRequestData.Kind.File(
|
||||
fileName = filename,
|
||||
mimeType = mimeType
|
||||
)
|
||||
)
|
||||
} else {
|
||||
MediaRequestData(
|
||||
source = thumbnailSource ?: mediaSource,
|
||||
kind = MediaRequestData.Kind.Thumbnail(
|
||||
width = thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH,
|
||||
height = thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ fun aTimelineItemImageContent(
|
|||
blurhash = blurhash,
|
||||
width = null,
|
||||
height = 300,
|
||||
thumbnailWidth = null,
|
||||
thumbnailHeight = 150,
|
||||
aspectRatio = aspectRatio,
|
||||
formattedFileSize = "4MB",
|
||||
fileExtension = "jpg"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ data class TimelineItemVideoContent(
|
|||
val blurHash: String?,
|
||||
val height: Int?,
|
||||
val width: Int?,
|
||||
val thumbnailWidth: Int?,
|
||||
val thumbnailHeight: Int?,
|
||||
val mimeType: String,
|
||||
val formattedFileSize: String,
|
||||
val fileExtension: String,
|
||||
|
|
|
|||
|
|
@ -35,8 +35,10 @@ fun aTimelineItemVideoContent(
|
|||
aspectRatio = aspectRatio,
|
||||
duration = 100.milliseconds,
|
||||
videoSource = MediaSource(""),
|
||||
height = 300,
|
||||
width = 150,
|
||||
height = 300,
|
||||
thumbnailWidth = 150,
|
||||
thumbnailHeight = 300,
|
||||
mimeType = MimeTypes.Mp4,
|
||||
formattedFileSize = "14MB",
|
||||
fileExtension = "mp4"
|
||||
|
|
|
|||
|
|
@ -324,6 +324,8 @@ class MessagesPresenterTest {
|
|||
blurhash = null,
|
||||
width = 20,
|
||||
height = 20,
|
||||
thumbnailWidth = null,
|
||||
thumbnailHeight = null,
|
||||
aspectRatio = 1.0f,
|
||||
fileExtension = "jpg",
|
||||
formattedFileSize = "4MB"
|
||||
|
|
@ -364,6 +366,8 @@ class MessagesPresenterTest {
|
|||
blurHash = null,
|
||||
width = 20,
|
||||
height = 20,
|
||||
thumbnailWidth = 20,
|
||||
thumbnailHeight = 20,
|
||||
aspectRatio = 1.0f,
|
||||
fileExtension = "mp4",
|
||||
formattedFileSize = "50MB"
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ import app.cash.turbine.test
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
|
||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
|
||||
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener
|
||||
import io.element.android.features.messages.impl.attachments.preview.SendActionState
|
||||
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
|
|
@ -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.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
|
@ -67,7 +71,11 @@ class AttachmentsPreviewPresenterTest {
|
|||
),
|
||||
sendFileResult = sendFileResult,
|
||||
)
|
||||
val presenter = createAttachmentsPreviewPresenter(room = room)
|
||||
val onDoneListener = lambdaRecorder<Unit> { }
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
room = room,
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -78,9 +86,28 @@ class AttachmentsPreviewPresenterTest {
|
|||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
||||
advanceUntilIdle()
|
||||
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(
|
||||
sendImageResult = sendImageResult,
|
||||
)
|
||||
val onDoneListener = lambdaRecorder<Unit> { }
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
room = room,
|
||||
mediaPreProcessor = mediaPreProcessor,
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -108,8 +137,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
initialState.textEditorState.setMarkdown(A_CAPTION)
|
||||
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
||||
advanceUntilIdle()
|
||||
sendImageResult.assertions().isCalledOnce().with(
|
||||
any(),
|
||||
any(),
|
||||
|
|
@ -118,6 +146,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
any(),
|
||||
any(),
|
||||
)
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,9 +162,11 @@ class AttachmentsPreviewPresenterTest {
|
|||
val room = FakeMatrixRoom(
|
||||
sendVideoResult = sendVideoResult,
|
||||
)
|
||||
val onDoneListener = lambdaRecorder<Unit> { }
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
room = room,
|
||||
mediaPreProcessor = mediaPreProcessor,
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -145,8 +176,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
initialState.textEditorState.setMarkdown(A_CAPTION)
|
||||
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
||||
advanceUntilIdle()
|
||||
sendVideoResult.assertions().isCalledOnce().with(
|
||||
any(),
|
||||
any(),
|
||||
|
|
@ -155,6 +185,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
any(),
|
||||
any(),
|
||||
)
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,11 +238,15 @@ class AttachmentsPreviewPresenterTest {
|
|||
room: MatrixRoom = FakeMatrixRoom(),
|
||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
||||
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||
onDoneListener: OnDoneListener = OnDoneListener {},
|
||||
): AttachmentsPreviewPresenter {
|
||||
return AttachmentsPreviewPresenter(
|
||||
attachment = aMediaAttachment(localMedia),
|
||||
onDoneListener = onDoneListener,
|
||||
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
temporaryUriDeleter = temporaryUriDeleter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,6 +246,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||
width = null,
|
||||
mimeType = MimeTypes.OctetStream,
|
||||
formattedFileSize = "0 Bytes",
|
||||
thumbnailWidth = null,
|
||||
thumbnailHeight = null,
|
||||
fileExtension = "",
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
|
|
@ -294,6 +296,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||
width = 300,
|
||||
mimeType = MimeTypes.Mp4,
|
||||
formattedFileSize = "555 Bytes",
|
||||
thumbnailWidth = 5,
|
||||
thumbnailHeight = 10,
|
||||
fileExtension = "mp4",
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
|
|
@ -458,6 +462,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||
blurhash = null,
|
||||
width = null,
|
||||
height = null,
|
||||
thumbnailWidth = null,
|
||||
thumbnailHeight = null,
|
||||
aspectRatio = null
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
|
|
@ -531,6 +537,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||
blurhash = A_BLUR_HASH,
|
||||
width = 5,
|
||||
height = 10,
|
||||
thumbnailWidth = 5,
|
||||
thumbnailHeight = 10,
|
||||
aspectRatio = 0.5f,
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import androidx.core.net.toUri
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
|
|
@ -43,6 +44,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
|
|||
private val matrixClient: MatrixClient,
|
||||
private val mediaPickerProvider: PickerProvider,
|
||||
private val mediaPreProcessor: MediaPreProcessor,
|
||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
) : Presenter<EditUserProfileState> {
|
||||
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
||||
|
|
@ -59,10 +61,20 @@ class EditUserProfilePresenter @AssistedInject constructor(
|
|||
var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) }
|
||||
var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) }
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
|
||||
onResult = { uri -> if (uri != null) userAvatarUri = uri }
|
||||
onResult = { uri ->
|
||||
if (uri != null) {
|
||||
temporaryUriDeleter.delete(userAvatarUri)
|
||||
userAvatarUri = uri
|
||||
}
|
||||
}
|
||||
)
|
||||
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
|
||||
onResult = { uri -> if (uri != null) userAvatarUri = uri }
|
||||
onResult = { uri ->
|
||||
if (uri != null) {
|
||||
temporaryUriDeleter.delete(userAvatarUri)
|
||||
userAvatarUri = uri
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val avatarActions by remember(userAvatarUri) {
|
||||
|
|
@ -96,7 +108,10 @@ class EditUserProfilePresenter @AssistedInject constructor(
|
|||
pendingPermissionRequest = true
|
||||
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
|
||||
}
|
||||
AvatarAction.Remove -> userAvatarUri = null
|
||||
AvatarAction.Remove -> {
|
||||
temporaryUriDeleter.delete(userAvatarUri)
|
||||
userAvatarUri = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +170,12 @@ class EditUserProfilePresenter @AssistedInject constructor(
|
|||
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
|
||||
return runCatching {
|
||||
if (avatarUri != null) {
|
||||
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
|
||||
val preprocessed = mediaPreProcessor.process(
|
||||
uri = avatarUri,
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
deleteOriginal = false,
|
||||
compressIfPossible = false,
|
||||
).getOrThrow()
|
||||
matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
||||
} else {
|
||||
matrixClient.removeAvatar().getOrThrow()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import app.cash.molecule.RecompositionMode
|
|||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
|
@ -29,6 +30,9 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
|
|||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
|
|
@ -73,12 +77,14 @@ class EditUserProfilePresenterTest {
|
|||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
matrixUser: MatrixUser = aMatrixUser(),
|
||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||
): EditUserProfilePresenter {
|
||||
return EditUserProfilePresenter(
|
||||
matrixClient = matrixClient,
|
||||
matrixUser = matrixUser,
|
||||
mediaPickerProvider = fakePickerProvider,
|
||||
mediaPreProcessor = fakeMediaPreProcessor,
|
||||
temporaryUriDeleter = temporaryUriDeleter,
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||
)
|
||||
}
|
||||
|
|
@ -107,7 +113,12 @@ class EditUserProfilePresenterTest {
|
|||
@Test
|
||||
fun `present - updates state in response to changes`() = runTest {
|
||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
val presenter = createEditUserProfilePresenter(matrixUser = user)
|
||||
val presenter = createEditUserProfilePresenter(
|
||||
matrixUser = user,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -136,7 +147,12 @@ class EditUserProfilePresenterTest {
|
|||
fun `present - obtains avatar uris from gallery`() = runTest {
|
||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
val presenter = createEditUserProfilePresenter(matrixUser = user)
|
||||
val presenter = createEditUserProfilePresenter(
|
||||
matrixUser = user,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -154,9 +170,13 @@ class EditUserProfilePresenterTest {
|
|||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
val fakePermissionsPresenter = FakePermissionsPresenter()
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createEditUserProfilePresenter(
|
||||
matrixUser = user,
|
||||
permissionsPresenter = fakePermissionsPresenter,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||
deleteLambda = deleteCallback,
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -177,6 +197,10 @@ class EditUserProfilePresenterTest {
|
|||
stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||
val stateWithNewAvatar2 = awaitItem()
|
||||
assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(userAvatarUri)
|
||||
deleteCallback.assertions().isCalledExactly(2).withSequence(
|
||||
listOf(value(userAvatarUri)),
|
||||
listOf(value(anotherAvatarUri)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,7 +208,13 @@ class EditUserProfilePresenterTest {
|
|||
fun `present - updates save button state`() = runTest {
|
||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
fakePickerProvider.givenResult(userAvatarUri)
|
||||
val presenter = createEditUserProfilePresenter(matrixUser = user)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createEditUserProfilePresenter(
|
||||
matrixUser = user,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||
deleteLambda = deleteCallback
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -210,6 +240,10 @@ class EditUserProfilePresenterTest {
|
|||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
deleteCallback.assertions().isCalledExactly(2).withSequence(
|
||||
listOf(value(userAvatarUri)),
|
||||
listOf(value(null)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,7 +251,13 @@ class EditUserProfilePresenterTest {
|
|||
fun `present - updates save button state when initial values are null`() = runTest {
|
||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = null)
|
||||
fakePickerProvider.givenResult(userAvatarUri)
|
||||
val presenter = createEditUserProfilePresenter(matrixUser = user)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createEditUserProfilePresenter(
|
||||
matrixUser = user,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||
deleteLambda = deleteCallback
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -243,6 +283,10 @@ class EditUserProfilePresenterTest {
|
|||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
deleteCallback.assertions().isCalledExactly(2).withSequence(
|
||||
listOf(value(null)),
|
||||
listOf(value(userAvatarUri)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -252,7 +296,10 @@ class EditUserProfilePresenterTest {
|
|||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
val presenter = createEditUserProfilePresenter(
|
||||
matrixClient = matrixClient,
|
||||
matrixUser = user
|
||||
matrixUser = user,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -318,7 +365,10 @@ class EditUserProfilePresenterTest {
|
|||
givenPickerReturnsFile()
|
||||
val presenter = createEditUserProfilePresenter(
|
||||
matrixClient = matrixClient,
|
||||
matrixUser = user
|
||||
matrixUser = user,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -337,7 +387,10 @@ class EditUserProfilePresenterTest {
|
|||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
val presenter = createEditUserProfilePresenter(
|
||||
matrixClient = matrixClient,
|
||||
matrixUser = user
|
||||
matrixUser = user,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||
),
|
||||
)
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
|
||||
|
|
@ -403,7 +456,13 @@ class EditUserProfilePresenterTest {
|
|||
}
|
||||
|
||||
private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) {
|
||||
val presenter = createEditUserProfilePresenter(matrixUser = matrixUser, matrixClient = matrixClient)
|
||||
val presenter = createEditUserProfilePresenter(
|
||||
matrixUser = matrixUser,
|
||||
matrixClient = matrixClient,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.net.toUri
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
|
|
@ -45,6 +46,7 @@ class RoomDetailsEditPresenter @Inject constructor(
|
|||
private val room: MatrixRoom,
|
||||
private val mediaPickerProvider: PickerProvider,
|
||||
private val mediaPreProcessor: MediaPreProcessor,
|
||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
) : Presenter<RoomDetailsEditState> {
|
||||
private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
||||
|
|
@ -59,6 +61,7 @@ class RoomDetailsEditPresenter @Inject constructor(
|
|||
var roomAvatarUriEdited by rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
LaunchedEffect(roomAvatarUri) {
|
||||
// Every time the roomAvatar change (from sync), we can set the new avatar.
|
||||
temporaryUriDeleter.delete(roomAvatarUriEdited)
|
||||
roomAvatarUriEdited = roomAvatarUri
|
||||
}
|
||||
|
||||
|
|
@ -98,10 +101,20 @@ class RoomDetailsEditPresenter @Inject constructor(
|
|||
}
|
||||
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
|
||||
onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri }
|
||||
onResult = { uri ->
|
||||
if (uri != null) {
|
||||
temporaryUriDeleter.delete(roomAvatarUriEdited)
|
||||
roomAvatarUriEdited = uri
|
||||
}
|
||||
}
|
||||
)
|
||||
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
|
||||
onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri }
|
||||
onResult = { uri ->
|
||||
if (uri != null) {
|
||||
temporaryUriDeleter.delete(roomAvatarUriEdited)
|
||||
roomAvatarUriEdited = uri
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(cameraPermissionState.permissionGranted) {
|
||||
|
|
@ -143,7 +156,10 @@ class RoomDetailsEditPresenter @Inject constructor(
|
|||
pendingPermissionRequest = true
|
||||
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
|
||||
}
|
||||
AvatarAction.Remove -> roomAvatarUriEdited = null
|
||||
AvatarAction.Remove -> {
|
||||
temporaryUriDeleter.delete(roomAvatarUriEdited)
|
||||
roomAvatarUriEdited = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +218,12 @@ class RoomDetailsEditPresenter @Inject constructor(
|
|||
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
|
||||
return runCatching {
|
||||
if (avatarUri != null) {
|
||||
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
|
||||
val preprocessed = mediaPreProcessor.process(
|
||||
uri = avatarUri,
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
deleteOriginal = false,
|
||||
compressIfPossible = false,
|
||||
).getOrThrow()
|
||||
room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
||||
} else {
|
||||
room.removeAvatar().getOrThrow()
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@
|
|||
package io.element.android.features.roomdetails.edit
|
||||
|
||||
import android.net.Uri
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.roomdetails.aMatrixRoom
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
|
|
@ -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.FakePermissionsPresenterFactory
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
|
|
@ -46,6 +46,7 @@ import org.junit.Rule
|
|||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
@Suppress("LargeClass")
|
||||
@ExperimentalCoroutinesApi
|
||||
class RoomDetailsEditPresenterTest {
|
||||
@get:Rule
|
||||
|
|
@ -77,12 +78,14 @@ class RoomDetailsEditPresenterTest {
|
|||
private fun createRoomDetailsEditPresenter(
|
||||
room: MatrixRoom,
|
||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||
): RoomDetailsEditPresenter {
|
||||
return RoomDetailsEditPresenter(
|
||||
room = room,
|
||||
mediaPickerProvider = fakePickerProvider,
|
||||
mediaPreProcessor = fakeMediaPreProcessor,
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||
temporaryUriDeleter = temporaryUriDeleter,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -95,10 +98,12 @@ class RoomDetailsEditPresenterTest {
|
|||
emitRoomInfo = true,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomId).isEqualTo(room.roomId)
|
||||
assertThat(initialState.roomRawName).isEqualTo(A_ROOM_RAW_NAME)
|
||||
|
|
@ -127,10 +132,12 @@ class RoomDetailsEditPresenterTest {
|
|||
}
|
||||
},
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
// Initially false
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.canChangeName).isFalse()
|
||||
|
|
@ -141,6 +148,7 @@ class RoomDetailsEditPresenterTest {
|
|||
assertThat(settledState.canChangeName).isTrue()
|
||||
assertThat(settledState.canChangeAvatar).isFalse()
|
||||
assertThat(settledState.canChangeTopic).isFalse()
|
||||
deleteCallback.assertions().isCalledOnce().with(value(null))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,10 +165,12 @@ class RoomDetailsEditPresenterTest {
|
|||
}
|
||||
}
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
// Initially false
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.canChangeName).isFalse()
|
||||
|
|
@ -187,10 +197,12 @@ class RoomDetailsEditPresenterTest {
|
|||
}
|
||||
}
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
// Initially false
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.canChangeName).isFalse()
|
||||
|
|
@ -213,10 +225,12 @@ class RoomDetailsEditPresenterTest {
|
|||
emitRoomInfo = true,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomTopic).isEqualTo("My topic")
|
||||
assertThat(initialState.roomRawName).isEqualTo("Name")
|
||||
|
|
@ -258,10 +272,12 @@ class RoomDetailsEditPresenterTest {
|
|||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
|
|
@ -282,13 +298,13 @@ class RoomDetailsEditPresenterTest {
|
|||
)
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
val fakePermissionsPresenter = FakePermissionsPresenter()
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
permissionsPresenter = fakePermissionsPresenter,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
|
||||
|
|
@ -305,6 +321,12 @@ class RoomDetailsEditPresenterTest {
|
|||
stateWithNewAvatar.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||
val stateWithNewAvatar2 = awaitItem()
|
||||
assertThat(stateWithNewAvatar2.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||
deleteCallback.assertions().isCalledExactly(4).withSequence(
|
||||
listOf(value(null)),
|
||||
listOf(value(null)),
|
||||
listOf(value(roomAvatarUri)),
|
||||
listOf(value(anotherAvatarUri)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -318,10 +340,12 @@ class RoomDetailsEditPresenterTest {
|
|||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
fakePickerProvider.givenResult(roomAvatarUri)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||
// Once a change is made, the save button is enabled
|
||||
|
|
@ -367,10 +391,12 @@ class RoomDetailsEditPresenterTest {
|
|||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
fakePickerProvider.givenResult(roomAvatarUri)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||
// Once a change is made, the save button is enabled
|
||||
|
|
@ -421,10 +447,12 @@ class RoomDetailsEditPresenterTest {
|
|||
removeAvatarResult = removeAvatarResult,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name"))
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
|
||||
|
|
@ -445,10 +473,12 @@ class RoomDetailsEditPresenterTest {
|
|||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name "))
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic "))
|
||||
|
|
@ -465,14 +495,17 @@ class RoomDetailsEditPresenterTest {
|
|||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
deleteCallback.assertions().isCalledOnce().with(value(null))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -484,14 +517,17 @@ class RoomDetailsEditPresenterTest {
|
|||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(""))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
deleteCallback.assertions().isCalledOnce().with(value(null))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -506,15 +542,21 @@ class RoomDetailsEditPresenterTest {
|
|||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
givenPickerReturnsFile()
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
skipItems(4)
|
||||
updateAvatarResult.assertions().isCalledOnce().with(value(MimeTypes.Jpeg), value(fakeFileContents))
|
||||
deleteCallback.assertions().isCalledExactly(2).withSequence(
|
||||
listOf(value(null)),
|
||||
listOf(value(null)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -528,10 +570,12 @@ class RoomDetailsEditPresenterTest {
|
|||
)
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
|
|
@ -576,7 +620,7 @@ class RoomDetailsEditPresenterTest {
|
|||
removeAvatarResult = { Result.failure(Throwable("!")) },
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove), deleteCallbackNumberOfInvocation = 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -590,7 +634,7 @@ class RoomDetailsEditPresenterTest {
|
|||
updateAvatarResult = { _, _ -> Result.failure(Throwable("!")) },
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto), deleteCallbackNumberOfInvocation = 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -603,10 +647,12 @@ class RoomDetailsEditPresenterTest {
|
|||
setTopicResult = { Result.failure(Throwable("!")) },
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo"))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
|
|
@ -617,17 +663,24 @@ class RoomDetailsEditPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun saveAndAssertFailure(room: MatrixRoom, event: RoomDetailsEditEvents) {
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
private suspend fun saveAndAssertFailure(
|
||||
room: MatrixRoom,
|
||||
event: RoomDetailsEditEvents,
|
||||
deleteCallbackNumberOfInvocation: Int = 2,
|
||||
) {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(event)
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
deleteCallback.assertions().isCalledExactly(deleteCallbackNumberOfInvocation)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ package io.element.android.features.verifysession.impl.incoming
|
|||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.InvisibleButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
|
|
@ -166,8 +165,7 @@ private fun IncomingVerificationBottomMenu(
|
|||
enabled = false,
|
||||
showProgress = true,
|
||||
)
|
||||
// Placeholder so the 1st button keeps its vertical position
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
InvisibleButton()
|
||||
}
|
||||
} else {
|
||||
VerificationBottomMenu {
|
||||
|
|
@ -194,8 +192,7 @@ private fun IncomingVerificationBottomMenu(
|
|||
enabled = false,
|
||||
showProgress = true,
|
||||
)
|
||||
// Placeholder so the 1st button keeps its vertical position
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
InvisibleButton()
|
||||
}
|
||||
} else {
|
||||
VerificationBottomMenu {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,8 @@ import androidx.compose.foundation.clickable
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.InvisibleButton
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
|
|
@ -282,8 +281,7 @@ private fun VerifySelfSessionBottomMenu(
|
|||
text = stringResource(CommonStrings.action_start_verification),
|
||||
onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
|
||||
)
|
||||
// Placeholder so the 1st button keeps its vertical position
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
InvisibleButton()
|
||||
}
|
||||
}
|
||||
is Step.Canceled -> {
|
||||
|
|
@ -293,8 +291,7 @@ private fun VerifySelfSessionBottomMenu(
|
|||
text = stringResource(CommonStrings.action_done),
|
||||
onClick = onCancelClick,
|
||||
)
|
||||
// Placeholder so the 1st button keeps its vertical position
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
InvisibleButton()
|
||||
}
|
||||
}
|
||||
is Step.Ready -> {
|
||||
|
|
@ -320,8 +317,7 @@ private fun VerifySelfSessionBottomMenu(
|
|||
showProgress = true,
|
||||
enabled = false,
|
||||
)
|
||||
// Placeholder so the 1st button keeps its vertical position
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
InvisibleButton()
|
||||
}
|
||||
}
|
||||
is Step.Verifying -> {
|
||||
|
|
@ -335,17 +331,22 @@ private fun VerifySelfSessionBottomMenu(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
text = positiveButtonTitle,
|
||||
showProgress = isVerifying,
|
||||
enabled = !isVerifying,
|
||||
onClick = {
|
||||
if (!isVerifying) {
|
||||
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||
}
|
||||
},
|
||||
)
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_they_dont_match),
|
||||
onClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
|
||||
)
|
||||
if (isVerifying) {
|
||||
InvisibleButton()
|
||||
} else {
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_they_dont_match),
|
||||
onClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Step.Completed -> {
|
||||
|
|
@ -355,8 +356,7 @@ private fun VerifySelfSessionBottomMenu(
|
|||
text = stringResource(CommonStrings.action_continue),
|
||||
onClick = onContinueClick,
|
||||
)
|
||||
// Placeholder so the 1st button keeps its vertical position
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
InvisibleButton()
|
||||
}
|
||||
}
|
||||
is Step.Skipped -> return
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ object MimeTypes {
|
|||
fun String?.normalizeMimeType() = if (this == BadJpg) Jpeg else this
|
||||
|
||||
fun String?.isMimeTypeImage() = this?.startsWith("image/").orFalse()
|
||||
fun String?.isMimeTypeAnimatedImage() = this == Gif || this == WebP
|
||||
fun String?.isMimeTypeVideo() = this?.startsWith("video/").orFalse()
|
||||
fun String?.isMimeTypeAudio() = this?.startsWith("audio/").orFalse()
|
||||
fun String?.isMimeTypeApplication() = this?.startsWith("application/").orFalse()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
|
|
@ -118,6 +119,14 @@ fun TextButton(
|
|||
leadingIcon = leadingIcon
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun InvisibleButton(
|
||||
modifier: Modifier = Modifier,
|
||||
size: ButtonSize = ButtonSize.Large,
|
||||
) {
|
||||
Spacer(modifier = modifier.height(size.toMinHeight()))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ButtonInternal(
|
||||
text: String,
|
||||
|
|
@ -131,14 +140,7 @@ private fun ButtonInternal(
|
|||
showProgress: Boolean = false,
|
||||
leadingIcon: IconSource? = null,
|
||||
) {
|
||||
val minHeight = when (size) {
|
||||
ButtonSize.Small -> 32.dp
|
||||
ButtonSize.Medium,
|
||||
ButtonSize.MediumLowPadding -> 40.dp
|
||||
ButtonSize.Large,
|
||||
ButtonSize.LargeLowPadding -> 48.dp
|
||||
}
|
||||
|
||||
val minHeight = size.toMinHeight()
|
||||
val hasStartDrawable = showProgress || leadingIcon != null
|
||||
|
||||
val contentPadding = when (size) {
|
||||
|
|
@ -253,6 +255,14 @@ private fun ButtonInternal(
|
|||
}
|
||||
}
|
||||
|
||||
private fun ButtonSize.toMinHeight() = when (this) {
|
||||
ButtonSize.Small -> 32.dp
|
||||
ButtonSize.Medium,
|
||||
ButtonSize.MediumLowPadding -> 40.dp
|
||||
ButtonSize.Large,
|
||||
ButtonSize.LargeLowPadding -> 48.dp
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface IconSource {
|
||||
val contentDescription: String?
|
||||
|
|
|
|||
|
|
@ -56,4 +56,7 @@ interface MatrixAuthenticationService {
|
|||
suspend fun loginWithOidc(callbackUrl: String): 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import io.element.android.services.analytics.api.AnalyticsService
|
|||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||
import org.matrix.rustcomponents.sdk.Session
|
||||
import org.matrix.rustcomponents.sdk.SlidingSyncVersion
|
||||
|
|
@ -51,8 +52,9 @@ class RustMatrixClientFactory @Inject constructor(
|
|||
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
|
||||
private val clientBuilderProvider: ClientBuilderProvider,
|
||||
) {
|
||||
private val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers)
|
||||
|
||||
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
|
||||
val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers)
|
||||
val client = getBaseClientBuilder(
|
||||
sessionPaths = sessionData.getSessionPaths(),
|
||||
passphrase = sessionData.passphrase,
|
||||
|
|
@ -60,18 +62,21 @@ class RustMatrixClientFactory @Inject constructor(
|
|||
)
|
||||
.homeserverUrl(sessionData.homeserverUrl)
|
||||
.username(sessionData.userId)
|
||||
.setSessionDelegate(sessionDelegate)
|
||||
.use { it.build() }
|
||||
|
||||
client.restoreSession(sessionData.toSession())
|
||||
|
||||
create(client)
|
||||
}
|
||||
|
||||
suspend fun create(client: Client): RustMatrixClient {
|
||||
val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
|
||||
|
||||
val syncService = client.syncService()
|
||||
.withUtdHook(UtdTracker(analyticsService))
|
||||
.finish()
|
||||
|
||||
val (anonymizedAccessToken, anonymizedRefreshToken) = sessionData.anonymizedTokens()
|
||||
|
||||
RustMatrixClient(
|
||||
return RustMatrixClient(
|
||||
client = client,
|
||||
baseDirectory = baseDirectory,
|
||||
sessionStore = sessionStore,
|
||||
|
|
@ -98,6 +103,7 @@ class RustMatrixClientFactory @Inject constructor(
|
|||
dataPath = sessionPaths.fileDirectory.absolutePath,
|
||||
cachePath = sessionPaths.cacheDirectory.absolutePath,
|
||||
)
|
||||
.setSessionDelegate(sessionDelegate)
|
||||
.passphrase(passphrase)
|
||||
.userAgent(userAgentProvider.provide())
|
||||
.addRootCertificates(userCertificatesProvider.provides())
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ import org.matrix.rustcomponents.sdk.QrCodeData
|
|||
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
|
||||
import org.matrix.rustcomponents.sdk.QrLoginProgress
|
||||
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk.OidcAuthorizationData
|
||||
import javax.inject.Inject
|
||||
|
|
@ -77,6 +76,11 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
private var currentClient: Client? = null
|
||||
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
||||
|
||||
private var newMatrixClientObserver: ((MatrixClient) -> Unit)? = null
|
||||
override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) {
|
||||
newMatrixClientObserver = lambda
|
||||
}
|
||||
|
||||
private fun rotateSessionPath(): SessionPaths {
|
||||
sessionPaths?.deleteRecursively()
|
||||
return sessionPathsFactory.create()
|
||||
|
|
@ -155,7 +159,7 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
passphrase = pendingPassphrase,
|
||||
sessionPaths = currentSessionPaths,
|
||||
)
|
||||
clear()
|
||||
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
|
||||
sessionStore.storeData(sessionData)
|
||||
SessionId(sessionData.userId)
|
||||
}.mapFailure { failure ->
|
||||
|
|
@ -226,9 +230,9 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
passphrase = pendingPassphrase,
|
||||
sessionPaths = currentSessionPaths,
|
||||
)
|
||||
clear()
|
||||
pendingOidcAuthorizationData?.close()
|
||||
pendingOidcAuthorizationData = null
|
||||
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
|
||||
sessionStore.storeData(sessionData)
|
||||
SessionId(sessionData.userId)
|
||||
}.mapFailure { failure ->
|
||||
|
|
@ -256,15 +260,14 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
oidcConfiguration = oidcConfiguration,
|
||||
progressListener = progressListener,
|
||||
)
|
||||
val sessionData = client.use { rustClient ->
|
||||
rustClient.session()
|
||||
.toSessionData(
|
||||
isTokenValid = true,
|
||||
loginType = LoginType.QR,
|
||||
passphrase = pendingPassphrase,
|
||||
sessionPaths = emptySessionPaths,
|
||||
)
|
||||
}
|
||||
val sessionData = client.session()
|
||||
.toSessionData(
|
||||
isTokenValid = true,
|
||||
loginType = LoginType.QR,
|
||||
passphrase = pendingPassphrase,
|
||||
sessionPaths = emptySessionPaths,
|
||||
)
|
||||
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
|
||||
sessionStore.storeData(sessionData)
|
||||
SessionId(sessionData.userId)
|
||||
}.mapFailure {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class RustMediaLoader(
|
|||
withContext(mediaDispatcher) {
|
||||
runCatching {
|
||||
source.toRustMediaSource().use { source ->
|
||||
innerClient.getMediaContent(source).toUByteArray().toByteArray()
|
||||
innerClient.getMediaContent(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ class RustMediaLoader(
|
|||
mediaSource = mediaSource,
|
||||
width = width.toULong(),
|
||||
height = height.toULong()
|
||||
).toUByteArray().toByteArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,15 +18,13 @@ import io.element.android.libraries.matrix.api.verification.VerificationFlowStat
|
|||
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
|
@ -61,11 +59,13 @@ class RustSessionVerificationService(
|
|||
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
|
||||
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow()
|
||||
|
||||
private val recoveryState = MutableStateFlow(RecoveryState.UNKNOWN)
|
||||
|
||||
// Listen for changes in verification status and update accordingly
|
||||
private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener {
|
||||
override fun onUpdate(status: VerificationState) {
|
||||
Timber.d("New verification state: $status")
|
||||
sessionCoroutineScope.launch { updateVerificationStatus() }
|
||||
_sessionVerifiedStatus.value = status.map()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ class RustSessionVerificationService(
|
|||
override fun onUpdate(status: RecoveryState) {
|
||||
Timber.d("New recovery state: $status")
|
||||
// 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
|
||||
}
|
||||
|
||||
init {
|
||||
// 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)
|
||||
}
|
||||
private var isOwnVerification = true
|
||||
|
||||
override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) {
|
||||
listener?.onIncomingSessionRequest(details.map())
|
||||
|
|
@ -135,6 +120,8 @@ class RustSessionVerificationService(
|
|||
}
|
||||
|
||||
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) = tryOrFail {
|
||||
isOwnVerification = false
|
||||
initVerificationControllerIfNeeded()
|
||||
verificationController.acknowledgeVerificationRequest(
|
||||
senderId = details.senderId.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
|
||||
// It also sometimes unexpectedly fails to report the session as verified, so we have to handle that possibility and fail if needed
|
||||
runCatching {
|
||||
withTimeout(30.seconds) {
|
||||
while (encryptionService.verificationState() != VerificationState.VERIFIED) {
|
||||
delay(100)
|
||||
}
|
||||
withTimeout(20.seconds) {
|
||||
// Wait until the SDK reports the state as verified
|
||||
sessionVerifiedStatus.first { it == SessionVerifiedStatus.Verified }
|
||||
}
|
||||
}
|
||||
.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
|
||||
updateVerificationStatus()
|
||||
}
|
||||
|
|
@ -209,6 +204,7 @@ class RustSessionVerificationService(
|
|||
// end-region
|
||||
|
||||
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
|
||||
isOwnVerification = true
|
||||
if (isReady.value && cancelAnyPendingVerificationAttempt) {
|
||||
// Cancel any pending verification attempt
|
||||
tryOrNull { verificationController.cancelVerification() }
|
||||
|
|
@ -237,37 +233,20 @@ class RustSessionVerificationService(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun updateVerificationStatus() {
|
||||
if (verificationFlowState.value == VerificationFlowState.DidFinish) {
|
||||
// Calling `encryptionService.verificationState()` performs a network call and it will deadlock if there is no network
|
||||
// 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("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 updateVerificationStatus() {
|
||||
runCatching {
|
||||
_sessionVerifiedStatus.value = encryptionService.verificationState().map()
|
||||
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 {
|
||||
return use { sessionVerificationData ->
|
||||
when (sessionVerificationData) {
|
||||
|
|
|
|||
|
|
@ -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.test.A_SESSION_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.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
|
|
@ -41,6 +42,7 @@ class FakeMatrixAuthenticationService(
|
|||
private var loginError: Throwable? = null
|
||||
private var changeServerError: Throwable? = null
|
||||
private var matrixClient: MatrixClient? = null
|
||||
private var onAuthenticationListener: ((MatrixClient) -> Unit)? = null
|
||||
|
||||
var getLatestSessionIdLambda: (() -> SessionId?) = { null }
|
||||
|
||||
|
|
@ -55,6 +57,7 @@ class FakeMatrixAuthenticationService(
|
|||
return it.invoke(sessionId)
|
||||
}
|
||||
return if (matrixClient != null) {
|
||||
onAuthenticationListener?.invoke(matrixClient!!)
|
||||
Result.success(matrixClient!!)
|
||||
} else {
|
||||
Result.failure(IllegalStateException())
|
||||
|
|
@ -74,7 +77,10 @@ class FakeMatrixAuthenticationService(
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -90,13 +96,21 @@ class FakeMatrixAuthenticationService(
|
|||
}
|
||||
|
||||
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 {
|
||||
onAuthenticationListener?.invoke(matrixClient ?: FakeMatrixClient())
|
||||
loginWithQrCodeResult(qrCodeData, progress)
|
||||
}
|
||||
|
||||
override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) {
|
||||
onAuthenticationListener = lambda
|
||||
}
|
||||
|
||||
fun givenOidcError(throwable: Throwable?) {
|
||||
oidcError = throwable
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ interface MediaPreProcessor {
|
|||
suspend fun process(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
deleteOriginal: Boolean = false,
|
||||
compressIfPossible: Boolean
|
||||
deleteOriginal: Boolean,
|
||||
compressIfPossible: Boolean,
|
||||
): Result<MediaUploadInfo>
|
||||
|
||||
data class Failure(override val cause: Throwable?) : Exception(cause)
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class MediaSender @Inject constructor(
|
|||
.process(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
deleteOriginal = true,
|
||||
deleteOriginal = false,
|
||||
compressIfPossible = compressIfPossible,
|
||||
)
|
||||
.flatMapCatching { info ->
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import android.media.MediaMetadataRetriever
|
|||
import android.net.Uri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
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.getFileName
|
||||
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.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
|
|
@ -49,6 +51,7 @@ class AndroidMediaPreProcessor @Inject constructor(
|
|||
private val imageCompressor: ImageCompressor,
|
||||
private val videoCompressor: VideoCompressor,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||
) : MediaPreProcessor {
|
||||
companion object {
|
||||
/**
|
||||
|
|
@ -82,8 +85,11 @@ class AndroidMediaPreProcessor @Inject constructor(
|
|||
}
|
||||
if (deleteOriginal) {
|
||||
tryOrNull {
|
||||
Timber.w("Deleting original uri $uri")
|
||||
contentResolver.delete(uri, null, null)
|
||||
}
|
||||
} else {
|
||||
temporaryUriDeleter.delete(uri)
|
||||
}
|
||||
result.postProcess(uri)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@
|
|||
package io.element.android.libraries.mediaupload.impl
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.net.toUri
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
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.matrix.api.media.AudioInfo
|
||||
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.MediaUploadInfo
|
||||
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 kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -42,7 +46,12 @@ class AndroidMediaPreProcessorTest {
|
|||
deleteOriginal: Boolean = false,
|
||||
): MediaUploadInfo {
|
||||
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 result = sut.process(
|
||||
uri = file.toUri(),
|
||||
|
|
@ -52,6 +61,7 @@ class AndroidMediaPreProcessorTest {
|
|||
)
|
||||
val data = result.getOrThrow()
|
||||
assertThat(data.file.path).endsWith(asset.filename)
|
||||
deleteCallback.assertions().isCalledExactly(if (deleteOriginal) 0 else 1)
|
||||
return data
|
||||
}
|
||||
|
||||
|
|
@ -356,13 +366,15 @@ class AndroidMediaPreProcessorTest {
|
|||
|
||||
private fun TestScope.createAndroidMediaPreProcessor(
|
||||
context: Context,
|
||||
sdkIntVersion: Int = Build.VERSION_CODES.P
|
||||
sdkIntVersion: Int = Build.VERSION_CODES.P,
|
||||
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||
) = AndroidMediaPreProcessor(
|
||||
context = context,
|
||||
thumbnailFactory = ThumbnailFactory(context, FakeBuildVersionSdkIntProvider(sdkIntVersion)),
|
||||
imageCompressor = ImageCompressor(context, testCoroutineDispatchers()),
|
||||
videoCompressor = VideoCompressor(context),
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
temporaryUriDeleter = temporaryUriDeleter,
|
||||
)
|
||||
|
||||
@Throws(IOException::class)
|
||||
|
|
|
|||
|
|
@ -32,9 +32,10 @@ class KonsistComposableTest {
|
|||
.withoutReceiverType()
|
||||
.withoutName(
|
||||
// Add some exceptions...
|
||||
"InvisibleButton",
|
||||
"OutlinedButton",
|
||||
"TextButton",
|
||||
"SimpleAlertDialogContent",
|
||||
"TextButton",
|
||||
)
|
||||
.assertTrue(
|
||||
additionalMessage =
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dbf6f78ad928bcc9878e546345a98d334ae92c9b81d4d8404892a16d19b446c3
|
||||
size 41534
|
||||
oid sha256:bea78fb1bb813bedce30e5b13257892bcc23a7d6eb1a404d24e764337568d6cd
|
||||
size 41596
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dfc69dc6d93a62e23df2f817ad5a167b1e94d7fb0d408d6ec0051d666b6cf175
|
||||
size 44869
|
||||
oid sha256:5d8163be5e84aa851df9666822a4d9bc7a40d3a99d6fa8498243c256b729d58f
|
||||
size 44724
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b76212b5942484621b7a58044a958203d838807d50687e8f4e2f9c8bdb6ad37c
|
||||
size 40232
|
||||
oid sha256:db8b048d11f766a8e3db28bac99f3e8dea34e6f37fba1eacd9e6f98f6127462b
|
||||
size 40383
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1033af0fc84e2819509fc798c17e8f0b07d74a08da99d0e059b7ff19db2ce56a
|
||||
size 43674
|
||||
oid sha256:c1098ef994ad9552aca2f9ad9efe7b1239133544e561e73eced6fa96d08eaa3b
|
||||
size 43785
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:18dadaebe7a32aacde31afa0352a343913955b099ca4a07851e3ffe75e88b4d6
|
||||
size 31012
|
||||
oid sha256:104d1d386398aac789420babf33755e03d163f40ae596cd8d24f35da0363fac6
|
||||
size 30952
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:71b9f32b26b391ff3bf231ca9f364a157f535c1e6ff52ee3e3ead3630bb1b239
|
||||
size 30007
|
||||
oid sha256:145c85570217621cc576f305ed72e27205381c438e434205e07b7363cfd67f04
|
||||
size 30069
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c5435926f4a54e99902b78881c59d9d816110e401bcb92baf3b2fca844338f31
|
||||
size 48182
|
||||
oid sha256:5d8163be5e84aa851df9666822a4d9bc7a40d3a99d6fa8498243c256b729d58f
|
||||
size 44724
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:000374157cb5fbf6670b4af041fc385538df78bf4aecaf83ea24d39dfec84f23
|
||||
size 24278
|
||||
oid sha256:781d869dff205f99d5a9bc9986abbe489bef24551e6fb695280541741895a508
|
||||
size 24238
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4dd274f8c2ade6213a13a47400ec3a571844eafcc82b292b1c813bd9aa098236
|
||||
size 30241
|
||||
oid sha256:85b92d23ab2f690c3c15ded7f14c513371cd36482fbeda1478f62c82650f1829
|
||||
size 30274
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:994b39ba25e011cce97504a1f3d4ed0c420b7bce87daafae77ba481cc629cdae
|
||||
size 29051
|
||||
oid sha256:b848d50320f1494c9d0edbc21bddcc773de0cf9b1c36ce034235fd3fbbd77cf1
|
||||
size 29210
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a6c90e5be60738ded8312822f95279aa76d3ec8d86265164f4e3e31dbcce61c5
|
||||
size 47355
|
||||
oid sha256:c1098ef994ad9552aca2f9ad9efe7b1239133544e561e73eced6fa96d08eaa3b
|
||||
size 43785
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a3f08002e805fe5f7a4c96aa4b73c2fcd6e8b79e6a9e82bcc7bd3df50d8c22d
|
||||
size 24015
|
||||
oid sha256:5931dfac0337cf856282d9c62983aa185fe208f2926868097238d5b95e222fe6
|
||||
size 23953
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue