Merge branch 'develop' into jonny/timeline-poll-edited

This commit is contained in:
Benoit Marty 2023-12-04 16:01:09 +01:00 committed by GitHub
commit f6ec76b5ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
602 changed files with 5298 additions and 1508 deletions

View file

@ -1,4 +1,3 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
@ -33,6 +32,7 @@ dependencies {
implementation(projects.libraries.di)
implementation(projects.libraries.core)
implementation(projects.services.toolbox.api)
implementation(libs.dagger)
implementation(libs.timber)
implementation(libs.androidx.corektx)
@ -41,4 +41,12 @@ dependencies {
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.security.crypto)
api(libs.androidx.browser)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
testImplementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
testImplementation(projects.services.toolbox.test)
}

View file

@ -22,16 +22,18 @@ import android.text.format.Formatter
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidFileSizeFormatter @Inject constructor(
@ApplicationContext private val context: Context,
) : FileSizeFormatter {
private val sdkIntProvider: BuildVersionSdkIntProvider,
) : FileSizeFormatter {
override fun format(fileSize: Long, useShortFormat: Boolean): String {
// Since Android O, the system considers that 1kB = 1000 bytes instead of 1024 bytes.
// We want to avoid that.
val normalizedSize = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) {
val normalizedSize = if (sdkIntProvider.get() <= Build.VERSION_CODES.N) {
fileSize
} else {
// First convert the size

View file

@ -16,8 +16,6 @@
package io.element.android.libraries.androidutils.system
import android.annotation.TargetApi
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
@ -31,6 +29,7 @@ import androidx.annotation.ChecksSdkIntAtLeast
import androidx.annotation.RequiresApi
import io.element.android.libraries.androidutils.R
import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat
import io.element.android.libraries.core.mimetype.MimeTypes
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
@ -104,19 +103,6 @@ fun Context.openAppSettingsPage(
}
}
/**
* Shows notification system settings for the given channel id.
*/
@TargetApi(Build.VERSION_CODES.O)
fun Activity.startNotificationChannelSettingsIntent(channelID: String) {
if (!supportNotificationChannels()) return
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, channelID)
}
startActivity(intent)
}
@RequiresApi(Build.VERSION_CODES.O)
fun Context.startInstallFromSourceIntent(
activityResultLauncher: ActivityResultLauncher<Intent>,
@ -140,7 +126,7 @@ fun Context.startSharePlainTextIntent(
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val share = Intent(Intent.ACTION_SEND)
share.type = "text/plain"
share.type = MimeTypes.PlainText
share.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
// Add data to the intent, the receiving app will decide what to do with it.
share.putExtra(Intent.EXTRA_SUBJECT, subject)

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.androidutils.filesize
import android.os.Build
import com.google.common.truth.Truth.assertThat
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class AndroidFileSizeFormatterTest {
@Test
fun `test api 24 long format`() {
val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.N)
assertThat(sut.format(1, useShortFormat = false)).isEqualTo("1.00B")
assertThat(sut.format(1000, useShortFormat = false)).isEqualTo("0.98KB")
assertThat(sut.format(1024, useShortFormat = false)).isEqualTo("1.00KB")
assertThat(sut.format(1024 * 1024, useShortFormat = false)).isEqualTo("1.00MB")
assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = false)).isEqualTo("1.00GB")
}
@Test
fun `test api 26 long format`() {
val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.O)
assertThat(sut.format(1, useShortFormat = false)).isEqualTo("1.00B")
assertThat(sut.format(1000, useShortFormat = false)).isEqualTo("0.98KB")
assertThat(sut.format(1024 * 1024, useShortFormat = false)).isEqualTo("0.95MB")
assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = false)).isEqualTo("0.93GB")
}
@Test
fun `test api 24 short format`() {
val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.N)
assertThat(sut.format(1, useShortFormat = true)).isEqualTo("1.0B")
assertThat(sut.format(1000, useShortFormat = true)).isEqualTo("0.98KB")
assertThat(sut.format(1024, useShortFormat = true)).isEqualTo("1.0KB")
assertThat(sut.format(1024 * 1024, useShortFormat = true)).isEqualTo("1.0MB")
assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = true)).isEqualTo("1.0GB")
}
@Test
fun `test api 26 short format`() {
val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.O)
assertThat(sut.format(1, useShortFormat = true)).isEqualTo("1.0B")
assertThat(sut.format(1000, useShortFormat = true)).isEqualTo("0.98KB")
assertThat(sut.format(1024 * 1024, useShortFormat = true)).isEqualTo("0.95MB")
assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = true)).isEqualTo("0.93GB")
}
private fun createAndroidFileSizeFormatter(sdkLevel: Int) = AndroidFileSizeFormatter(
context = RuntimeEnvironment.getApplication(),
sdkIntProvider = FakeBuildVersionSdkIntProvider(sdkInt = sdkLevel)
)
}

View file

@ -21,27 +21,36 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
inline fun <reified NODE : Node> Node.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE {
inline fun <reified N : Node> Node.createNode(
buildContext: BuildContext,
plugins: List<Plugin> = emptyList()
): N {
val bindings: NodeFactoriesBindings = bindings()
return bindings.createNode(context, plugins)
return bindings.createNode(buildContext, plugins)
}
inline fun <reified NODE : Node> Context.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE {
inline fun <reified N : Node> Context.createNode(
buildContext: BuildContext,
plugins: List<Plugin> = emptyList()
): N {
val bindings: NodeFactoriesBindings = bindings()
return bindings.createNode(context, plugins)
return bindings.createNode(buildContext, plugins)
}
inline fun <reified NODE : Node> NodeFactoriesBindings.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE {
val nodeClass = NODE::class.java
inline fun <reified N : Node> NodeFactoriesBindings.createNode(
buildContext: BuildContext,
plugins: List<Plugin> = emptyList()
): N {
val nodeClass = N::class.java
val nodeFactoryMap = nodeFactories()
// Note to developers: If you got the error below, make sure to build again after
// clearing the cache (sometimes several times) to let Dagger generate the NodeFactory.
val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.name}.")
@Suppress("UNCHECKED_CAST")
val castedNodeFactory = nodeFactory as? AssistedNodeFactory<NODE>
val node = castedNodeFactory?.create(context, plugins)
return node as NODE
val castedNodeFactory = nodeFactory as? AssistedNodeFactory<N>
val node = castedNodeFactory?.create(buildContext, plugins)
return node as N
}
interface NodeFactoriesBindings {

View file

@ -60,4 +60,11 @@ object MimeTypes {
else -> OctetStream
}
}
fun hasSubtype(mimeType: String): Boolean {
val components = mimeType.split("/")
if (components.size != 2) return false
val subType = components.last()
return subType.isNotBlank() && subType != "*"
}
}

View file

@ -25,7 +25,6 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@ -58,14 +57,13 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultRoomLastMessageFormatter @Inject constructor(
private val sp: StringProvider,
private val matrixClient: MatrixClient,
private val roomMembershipContentFormatter: RoomMembershipContentFormatter,
private val profileChangeContentFormatter: ProfileChangeContentFormatter,
private val stateContentFormatter: StateContentFormatter,
) : RoomLastMessageFormatter {
override fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? {
val isOutgoing = matrixClient.isMe(event.sender)
val isOutgoing = event.isOwn
val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
return when (val content = event.content) {
is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom)

View file

@ -21,7 +21,6 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -42,7 +41,6 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultTimelineEventFormatter @Inject constructor(
private val sp: StringProvider,
private val matrixClient: MatrixClient,
private val buildMeta: BuildMeta,
private val roomMembershipContentFormatter: RoomMembershipContentFormatter,
private val profileChangeContentFormatter: ProfileChangeContentFormatter,
@ -50,7 +48,7 @@ class DefaultTimelineEventFormatter @Inject constructor(
) : TimelineEventFormatter {
override fun format(event: EventTimelineItem): CharSequence? {
val isOutgoing = matrixClient.isMe(event.sender)
val isOutgoing = event.isOwn
val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
return when (val content = event.content) {
is RoomMembershipContent -> {

View file

@ -75,7 +75,6 @@ class DefaultRoomLastMessageFormatterTest {
val stringProvider = AndroidStringProvider(context.resources)
formatter = DefaultRoomLastMessageFormatter(
sp = AndroidStringProvider(context.resources),
matrixClient = fakeMatrixClient,
roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider),
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
stateContentFormatter = StateContentFormatter(stringProvider)
@ -798,6 +797,11 @@ class DefaultRoomLastMessageFormatterTest {
private fun createRoomEvent(sentByYou: Boolean, senderDisplayName: String?, content: EventContent): EventTimelineItem {
val sender = if (sentByYou) A_USER_ID else UserId("@someone_else:domain")
val profile = ProfileTimelineDetails.Ready(senderDisplayName, false, null)
return anEventTimelineItem(content = content, senderProfile = profile, sender = sender)
return anEventTimelineItem(
content = content,
senderProfile = profile,
sender = sender,
isOwn = sentByYou,
)
}
}

View file

@ -41,7 +41,7 @@ interface MatrixClient : Closeable {
val roomListService: RoomListService
val mediaLoader: MatrixMediaLoader
suspend fun getRoom(roomId: RoomId): MatrixRoom?
suspend fun findDM(userId: UserId): MatrixRoom?
suspend fun findDM(userId: UserId): RoomId?
suspend fun ignoreUser(userId: UserId): Result<Unit>
suspend fun unignoreUser(userId: UserId): Result<Unit>
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>

View file

@ -16,14 +16,12 @@
package io.element.android.libraries.matrix.api.encryption
import androidx.compose.runtime.Immutable
@Immutable
sealed interface BackupUploadState {
data object Unknown : BackupUploadState
data class CheckingIfUploadNeeded(
val backedUpCount: Int,
val totalCount: Int,
) : BackupUploadState
data object Waiting : BackupUploadState
data class Uploading(

View file

@ -17,9 +17,10 @@
package io.element.android.libraries.matrix.api.encryption
sealed interface EnableRecoveryProgress {
data object Unknown : EnableRecoveryProgress
data object CreatingRecoveryKey : EnableRecoveryProgress
data object Starting : EnableRecoveryProgress
data object CreatingBackup : EnableRecoveryProgress
data object CreatingRecoveryKey : EnableRecoveryProgress
data class BackingUp(val backedUpCount: Int, val totalCount: Int) : EnableRecoveryProgress
data object RoomKeyUploadError : EnableRecoveryProgress
data class Done(val recoveryKey: String) : EnableRecoveryProgress
}

View file

@ -16,6 +16,9 @@
package io.element.android.libraries.matrix.api.encryption
import androidx.compose.runtime.Immutable
@Immutable
sealed interface SteadyStateException {
/**
* The backup can be deleted.

View file

@ -16,9 +16,10 @@
package io.element.android.libraries.matrix.api.media
import java.time.Duration
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
data class AudioDetails(
val duration: Duration,
val waveform: List<Float>,
val waveform: ImmutableList<Float>,
)

View file

@ -16,7 +16,7 @@
package io.element.android.libraries.matrix.api.media
import java.time.Duration
import kotlin.time.Duration
data class AudioInfo(
val duration: Duration?,

View file

@ -16,7 +16,7 @@
package io.element.android.libraries.matrix.api.media
import java.time.Duration
import kotlin.time.Duration
data class VideoInfo(
val duration: Duration?,

View file

@ -17,18 +17,21 @@
package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
/**
* This sealed class represents all the permalink cases.
* You don't have to instantiate yourself but should use [PermalinkParser] instead.
*/
@Immutable
sealed interface PermalinkData {
data class RoomLink(
val roomIdOrAlias: String,
val isRoomAlias: Boolean,
val eventId: String?,
val viaParameters: List<String>
val viaParameters: ImmutableList<String>
) : PermalinkData
/*

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
import android.net.UrlQuerySanitizer
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
import java.net.URLDecoder
@ -80,7 +81,7 @@ object PermalinkParser {
roomIdOrAlias = decodedIdentifier,
isRoomAlias = true,
eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
viaParameters = viaQueryParameters
viaParameters = viaQueryParameters.toImmutableList()
)
}
else -> PermalinkData.FallbackLink(uri, MatrixPatterns.isGroupId(identifier))
@ -119,7 +120,7 @@ object PermalinkParser {
roomIdOrAlias = identifier,
isRoomAlias = false,
eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
viaParameters = viaQueryParameters
viaParameters = viaQueryParameters.toImmutableList()
)
}
}

View file

@ -132,6 +132,8 @@ interface MatrixRoom : Closeable {
suspend fun canUserTriggerRoomNotification(userId: UserId): Result<Boolean>
suspend fun canUserJoinCall(userId: UserId): Result<Boolean>
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit>
suspend fun removeAvatar(): Result<Unit>
@ -238,5 +240,7 @@ interface MatrixRoom : Closeable {
*/
fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver>
suspend fun pollHistory(): MatrixTimeline
override fun close() = destroy()
}

View file

@ -16,8 +16,11 @@
package io.element.android.libraries.matrix.api.room
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class MatrixRoomInfo(
val id: String,
val name: String?,
@ -28,7 +31,7 @@ data class MatrixRoomInfo(
val isSpace: Boolean,
val isTombstoned: Boolean,
val canonicalAlias: String?,
val alternativeAliases: List<String>,
val alternativeAliases: ImmutableList<String>,
val currentUserMembership: CurrentUserMembership,
val latestEvent: EventTimelineItem?,
val inviter: RoomMember?,
@ -39,5 +42,5 @@ data class MatrixRoomInfo(
val notificationCount: Long,
val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean,
val activeRoomCallParticipants: List<String>
val activeRoomCallParticipants: ImmutableList<String>
)

View file

@ -17,13 +17,14 @@
package io.element.android.libraries.matrix.api.room
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
@Immutable
sealed interface MatrixRoomMembersState {
data object Unknown : MatrixRoomMembersState
data class Pending(val prevRoomMembers: List<RoomMember>? = null) : MatrixRoomMembersState
data class Error(val failure: Throwable, val prevRoomMembers: List<RoomMember>? = null) : MatrixRoomMembersState
data class Ready(val roomMembers: List<RoomMember>) : MatrixRoomMembersState
data class Pending(val prevRoomMembers: ImmutableList<RoomMember>? = null) : MatrixRoomMembersState
data class Error(val failure: Throwable, val prevRoomMembers: ImmutableList<RoomMember>? = null) : MatrixRoomMembersState
data class Ready(val roomMembers: ImmutableList<RoomMember>) : MatrixRoomMembersState
}
fun MatrixRoomMembersState.roomMembers(): List<RoomMember>? {

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
/**
* Try to find an existing DM with the given user, or create one if none exists.
*/
suspend fun MatrixClient.startDM(userId: UserId): StartDMResult {
val existingDM = findDM(userId)
return if (existingDM != null) {
StartDMResult.Success(existingDM, isNew = false)
} else {
createDM(userId).fold(
{ StartDMResult.Success(it, isNew = true) },
{ StartDMResult.Failure(it) }
)
}
}
sealed interface StartDMResult {
data class Success(val roomId: RoomId, val isNew: Boolean) : StartDMResult
data class Failure(val throwable: Throwable) : StartDMResult
}

View file

@ -16,6 +16,12 @@
package io.element.android.libraries.matrix.api.roomlist
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* RoomList with dynamic filtering and loading.
* This is useful for large lists of rooms.
@ -23,17 +29,17 @@ package io.element.android.libraries.matrix.api.roomlist
*/
interface DynamicRoomList : RoomList {
companion object {
const val DEFAULT_PAGE_SIZE = 20
const val DEFAULT_PAGES_TO_LOAD = 10
}
sealed interface Filter {
/**
* No filter applied.
*/
data object All : Filter
/**
* Filter only the left rooms.
*/
data object AllNonLeft : Filter
/**
* Filter all rooms.
*/
@ -45,6 +51,10 @@ interface DynamicRoomList : RoomList {
data class NormalizedMatchRoomName(val pattern: String) : Filter
}
val currentFilter: StateFlow<Filter>
val loadedPages: StateFlow<Int>
val pageSize: Int
/**
* Load more rooms into the list if possible.
*/
@ -61,3 +71,29 @@ interface DynamicRoomList : RoomList {
*/
suspend fun updateFilter(filter: Filter)
}
/**
* Offers a way to load all the rooms incrementally.
* It will load more room until all are loaded.
* If total number of rooms increase, it will load more pages if needed.
* The number of rooms is independent of the filter.
*/
fun DynamicRoomList.loadAllIncrementally(coroutineScope: CoroutineScope) {
combine(
loadedPages,
loadingState,
) { loadedPages, loadingState ->
loadedPages to loadingState
}
.onEach { (loadedPages, loadingState) ->
when (loadingState) {
is RoomList.LoadingState.Loaded -> {
if (pageSize * loadedPages < loadingState.numberOfRooms) {
loadMore()
}
}
RoomList.LoadingState.NotLoaded -> Unit
}
}
.launchIn(coroutineScope)
}

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.api.roomlist
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.flow.StateFlow
/**
@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.StateFlow
*/
interface RoomListService {
@Immutable
sealed interface State {
data object Idle : State
data object Running : State
@ -32,16 +34,17 @@ interface RoomListService {
data object Terminated : State
}
@Immutable
sealed interface SyncIndicator {
data object Show : SyncIndicator
data object Hide : SyncIndicator
}
/**
* returns a [RoomList] object of all rooms we want to display.
* returns a [DynamicRoomList] object of all rooms we want to display.
* This will exclude some rooms like the invites, or spaces.
*/
val allRooms: RoomList
val allRooms: DynamicRoomList
/**
* returns a [RoomList] object of all invites.

View file

@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface MatrixTimeline {
interface MatrixTimeline: AutoCloseable {
data class PaginationState(
val isBackPaginating: Boolean,

View file

@ -16,11 +16,15 @@
package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@Immutable
sealed interface EventContent
data class MessageContent(
@ -43,8 +47,8 @@ data class PollContent(
val question: String,
val kind: PollKind,
val maxSelections: ULong,
val answers: List<PollAnswer>,
val votes: Map<String, List<UserId>>,
val answers: ImmutableList<PollAnswer>,
val votes: ImmutableMap<String, ImmutableList<UserId>>,
val endTime: ULong?,
val isEdited: Boolean,
) : EventContent
@ -52,6 +56,8 @@ data class PollContent(
data class UnableToDecryptContent(
val data: Data
) : EventContent {
@Immutable
sealed interface Data {
data class OlmV1Curve25519AesSha2(
val senderKey: String

View file

@ -16,7 +16,11 @@
package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class EventReaction(
val key: String,
val senders: List<ReactionSender>
val senders: ImmutableList<ReactionSender>
)

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import kotlinx.collections.immutable.ImmutableList
data class EventTimelineItem(
val eventId: EventId?,
@ -29,8 +30,8 @@ data class EventTimelineItem(
val isOwn: Boolean,
val isRemote: Boolean,
val localSendState: LocalEventSendState?,
val reactions: List<EventReaction>,
val receipts: List<Receipt>,
val reactions: ImmutableList<EventReaction>,
val receipts: ImmutableList<Receipt>,
val sender: UserId,
val senderProfile: ProfileTimelineDetails,
val timestamp: Long,

View file

@ -16,9 +16,11 @@
package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@Immutable
sealed interface InReplyTo {
/** The event details are not loaded yet. We can fetch them. */
data class NotLoaded(val eventId: EventId) : InReplyTo

View file

@ -16,8 +16,10 @@
package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
@Immutable
sealed interface LocalEventSendState {
data object NotSentYet : LocalEventSendState
data object Canceled : LocalEventSendState

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.media.AudioDetails
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
@ -23,6 +24,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.VideoInfo
@Immutable
sealed interface MessageType
data class EmoteMessageType(

View file

@ -16,6 +16,9 @@
package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
@Immutable
sealed interface OtherState {
data object PolicyRuleRoom : OtherState
data object PolicyRuleServer : OtherState

View file

@ -16,6 +16,9 @@
package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
@Immutable
sealed interface ProfileTimelineDetails {
data object Unavailable : ProfileTimelineDetails

View file

@ -16,7 +16,9 @@
package io.element.android.libraries.matrix.api.user
import kotlinx.collections.immutable.ImmutableList
data class MatrixSearchUserResults(
val results: List<MatrixUser>,
val results: ImmutableList<MatrixUser>,
val limited: Boolean,
)

View file

@ -16,6 +16,8 @@
package io.element.android.libraries.matrix.api.verification
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@ -75,6 +77,7 @@ interface SessionVerificationService {
}
/** Verification status of the current session. */
@Immutable
sealed interface SessionVerifiedStatus {
/** Unknown status, we couldn't read the actual value from the SDK. */
data object Unknown : SessionVerifiedStatus
@ -87,6 +90,7 @@ sealed interface SessionVerifiedStatus {
}
/** States produced by the [SessionVerificationService]. */
@Immutable
sealed interface VerificationFlowState {
/** Initial state. */
data object Initial : VerificationFlowState
@ -98,7 +102,7 @@ sealed interface VerificationFlowState {
data object StartedSasVerification : VerificationFlowState
/** Verification data for the SAS verification (emojis) received. */
data class ReceivedVerificationData(val emoji: List<VerificationEmoji>) : VerificationFlowState
data class ReceivedVerificationData(val emoji: ImmutableList<VerificationEmoji>) : VerificationFlowState
/** Verification completed successfully. */
data object Finished : VerificationFlowState

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.permalink
import com.google.common.truth.Truth.assertThat
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@ -66,7 +67,7 @@ class PermalinkParserTest {
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
eventId = null,
viaParameters = emptyList(),
viaParameters = persistentListOf(),
)
)
}
@ -79,7 +80,7 @@ class PermalinkParserTest {
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
eventId = "\$1234567890abcdef:matrix.org",
viaParameters = emptyList(),
viaParameters = persistentListOf(),
)
)
}
@ -92,7 +93,7 @@ class PermalinkParserTest {
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
eventId = null,
viaParameters = emptyList(),
viaParameters = persistentListOf(),
)
)
}
@ -105,7 +106,7 @@ class PermalinkParserTest {
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
eventId = "\$1234567890abcdef:matrix.org",
viaParameters = listOf("matrix.org", "matrix.com"),
viaParameters = persistentListOf("matrix.org", "matrix.com"),
)
)
}
@ -118,7 +119,7 @@ class PermalinkParserTest {
roomIdOrAlias = "#element-android:matrix.org",
isRoomAlias = true,
eventId = null,
viaParameters = emptyList(),
viaParameters = persistentListOf(),
)
)
}

View file

@ -47,6 +47,7 @@ dependencies {
implementation("net.java.dev.jna:jna:5.13.0@aar")
implementation(libs.androidx.datastore.preferences)
implementation(libs.serialization.json)
implementation(libs.kotlinx.collections.immutable)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)

View file

@ -77,6 +77,7 @@ import org.matrix.rustcomponents.sdk.BackupState
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.TaskHandle
@ -214,6 +215,7 @@ class RustMatrixClient constructor(
isKeyBackupEnabled = client.encryption().backupState() == BackupState.ENABLED,
roomListItem = roomListItem,
innerRoom = fullRoom,
innerTimeline = fullRoom.timeline(),
roomNotificationSettingsService = notificationSettingsService,
sessionCoroutineScope = sessionCoroutineScope,
coroutineDispatchers = dispatchers,
@ -239,9 +241,8 @@ class RustMatrixClient constructor(
}
}
override suspend fun findDM(userId: UserId): MatrixRoom? {
val roomId = client.getDmRoom(userId.value)?.use { RoomId(it.id()) }
return roomId?.let { getRoom(it) }
override suspend fun findDM(userId: UserId): RoomId? {
return client.getDmRoom(userId.value)?.use { RoomId(it.id()) }
}
override suspend fun ignoreUser(userId: UserId): Result<Unit> = withContext(sessionDispatcher) {
@ -274,6 +275,7 @@ class RustMatrixClient constructor(
},
invite = createRoomParams.invite?.map { it.value },
avatar = createRoomParams.avatar,
powerLevelContentOverride = defaultRoomCreationPowerLevels,
)
val roomId = RoomId(client.createRoom(rustParams))
@ -296,7 +298,7 @@ class RustMatrixClient constructor(
isDirect = true,
visibility = RoomVisibility.PRIVATE,
preset = RoomPreset.TRUSTED_PRIVATE_CHAT,
invite = listOf(userId)
invite = listOf(userId),
)
return createRoom(createRoomParams)
}
@ -481,3 +483,18 @@ class RustMatrixClient constructor(
}
}
private val defaultRoomCreationPowerLevels = PowerLevels(
usersDefault = null,
eventsDefault = null,
stateDefault = null,
ban = null,
kick = null,
redact = null,
invite = null,
notifications = null,
users = mapOf(),
events = mapOf(
"m.call.member" to 0,
"org.matrix.msc3401.call.member" to 0,
)
)

View file

@ -22,11 +22,6 @@ import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState
class BackupUploadStateMapper {
fun map(rustEnableProgress: RustBackupUploadState): BackupUploadState {
return when (rustEnableProgress) {
is RustBackupUploadState.CheckingIfUploadNeeded ->
BackupUploadState.CheckingIfUploadNeeded(
backedUpCount = rustEnableProgress.backedUpCount.toInt(),
totalCount = rustEnableProgress.totalCount.toInt(),
)
RustBackupUploadState.Done ->
BackupUploadState.Done
is RustBackupUploadState.Uploading ->

View file

@ -22,12 +22,14 @@ import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecover
class EnableRecoveryProgressMapper {
fun map(rustEnableProgress: RustEnableRecoveryProgress): EnableRecoveryProgress {
return when (rustEnableProgress) {
is RustEnableRecoveryProgress.CreatingRecoveryKey -> EnableRecoveryProgress.CreatingRecoveryKey
is RustEnableRecoveryProgress.Starting -> EnableRecoveryProgress.Starting
is RustEnableRecoveryProgress.CreatingBackup -> EnableRecoveryProgress.CreatingBackup
is RustEnableRecoveryProgress.CreatingRecoveryKey -> EnableRecoveryProgress.CreatingRecoveryKey
is RustEnableRecoveryProgress.BackingUp -> EnableRecoveryProgress.BackingUp(
backedUpCount = rustEnableProgress.backedUpCount.toInt(),
totalCount = rustEnableProgress.totalCount.toInt(),
)
is RustEnableRecoveryProgress.RoomKeyUploadError -> EnableRecoveryProgress.RoomKeyUploadError
is RustEnableRecoveryProgress.Done -> EnableRecoveryProgress.Done(
recoveryKey = rustEnableProgress.recoveryKey
)

View file

@ -86,7 +86,7 @@ internal class RustEncryptionService(
}
}.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RecoveryState.WAITING_FOR_SYNC)
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Unknown)
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Starting)
fun start() {
service.backupStateListener(object : BackupStateListener {
@ -181,7 +181,7 @@ internal class RustEncryptionService(
override suspend fun fixRecoveryIssues(recoveryKey: String): Result<Unit> = withContext(dispatchers.io) {
runCatching {
service.fixRecoveryIssues(recoveryKey)
service.recover(recoveryKey)
}
}
}

View file

@ -23,13 +23,13 @@ class SteadyStateExceptionMapper {
fun map(data: RustSteadyStateException): SteadyStateException {
return when (data) {
is RustSteadyStateException.BackupDisabled -> SteadyStateException.BackupDisabled(
message = data.message
message = data.message.orEmpty()
)
is RustSteadyStateException.Connection -> SteadyStateException.Connection(
message = data.message
message = data.message.orEmpty()
)
is RustSteadyStateException.Laged -> SteadyStateException.Lagged(
message = data.message
is RustSteadyStateException.Lagged -> SteadyStateException.Lagged(
message = data.message.orEmpty()
)
}
}

View file

@ -17,15 +17,18 @@
package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.matrix.api.media.AudioDetails
import kotlinx.collections.immutable.toImmutableList
import kotlin.time.toJavaDuration
import kotlin.time.toKotlinDuration
import org.matrix.rustcomponents.sdk.UnstableAudioDetailsContent as RustAudioDetails
fun RustAudioDetails.map(): AudioDetails = AudioDetails(
duration = duration,
waveform = waveform.fromMSC3246range(),
duration = duration.toKotlinDuration(),
waveform = waveform.fromMSC3246range().toImmutableList(),
)
fun AudioDetails.map(): RustAudioDetails = RustAudioDetails(
duration = duration,
duration = duration.toJavaDuration(),
waveform = waveform.toMSC3246range()
)

View file

@ -17,16 +17,18 @@
package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.matrix.api.media.AudioInfo
import kotlin.time.toJavaDuration
import kotlin.time.toKotlinDuration
import org.matrix.rustcomponents.sdk.AudioInfo as RustAudioInfo
fun RustAudioInfo.map(): AudioInfo = AudioInfo(
duration = duration,
duration = duration?.toKotlinDuration(),
size = size?.toLong(),
mimetype = mimetype
)
fun AudioInfo.map(): RustAudioInfo = RustAudioInfo(
duration = duration,
duration = duration?.toJavaDuration(),
size = size?.toULong(),
mimetype = mimetype,
)

View file

@ -82,7 +82,7 @@ class RustMediaLoader(
val mediaFile = innerClient.getMediaFile(
mediaSource = mediaSource,
body = body,
mimeType = mimeType ?: MimeTypes.OctetStream,
mimeType = mimeType?.takeIf { MimeTypes.hasSubtype(it) } ?: MimeTypes.OctetStream,
useCache = useCache,
tempDir = cacheDirectory.path,
)

View file

@ -17,10 +17,12 @@
package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.matrix.api.media.VideoInfo
import kotlin.time.toJavaDuration
import kotlin.time.toKotlinDuration
import org.matrix.rustcomponents.sdk.VideoInfo as RustVideoInfo
fun RustVideoInfo.map(): VideoInfo = VideoInfo(
duration = duration,
duration = duration?.toKotlinDuration(),
height = height?.toLong(),
width = width?.toLong(),
mimetype = mimetype,
@ -31,7 +33,7 @@ fun RustVideoInfo.map(): VideoInfo = VideoInfo(
)
fun VideoInfo.map(): RustVideoInfo = RustVideoInfo(
duration = duration,
duration = duration?.toJavaDuration(),
height = height?.toULong(),
width = width?.toULong(),
mimetype = mimetype,

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.Membership as RustMembership
import org.matrix.rustcomponents.sdk.RoomInfo as RustRoomInfo
@ -40,7 +41,7 @@ class MatrixRoomInfoMapper(
isSpace = it.isSpace,
isTombstoned = it.isTombstoned,
canonicalAlias = it.canonicalAlias,
alternativeAliases = it.alternativeAliases,
alternativeAliases = it.alternativeAliases.toImmutableList(),
currentUserMembership = it.membership.map(),
latestEvent = it.latestEvent?.use (timelineItemMapper::map),
inviter = it.inviter?.use(RoomMemberMapper::map),
@ -51,7 +52,7 @@ class MatrixRoomInfoMapper(
notificationCount = it.notificationCount.toLong(),
userDefinedNotificationMode = it.userDefinedNotificationMode?.map(),
hasRoomCall = it.hasRoomCall,
activeRoomCallParticipants = it.activeRoomCallParticipants
activeRoomCallParticipants = it.activeRoomCallParticipants.toImmutableList()
)
}
}

View file

@ -24,8 +24,8 @@ import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.libraries.matrix.impl.timeline.runWithTimelineListenerRegistered
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListService
import org.matrix.rustcomponents.sdk.Timeline
import kotlin.time.Duration.Companion.milliseconds
/**
@ -37,19 +37,19 @@ class RoomContentForwarder(
) {
/**
* Forwards the event with the given [eventId] from the [fromRoom] to the given [toRoomIds].
* @param fromRoom the room to forward the event from
* Forwards the event with the given [eventId] from the [fromTimeline] to the given [toRoomIds].
* @param fromTimeline the room to forward the event from
* @param eventId the id of the event to forward
* @param toRoomIds the ids of the rooms to forward the event to
* @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room
*/
suspend fun forward(
fromRoom: Room,
fromTimeline: Timeline,
eventId: EventId,
toRoomIds: List<RoomId>,
timeoutMs: Long = 5000L
) {
val content = fromRoom.getTimelineEventContentByEventId(eventId.value)
val content = fromTimeline.getTimelineEventContentByEventId(eventId.value)
val targetSlidingSyncRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val targetRooms = targetSlidingSyncRooms.mapNotNull { slidingSyncRoom -> slidingSyncRoom.use { it.fullRoom() } }
val failedForwardingTo = mutableSetOf<RoomId>()
@ -57,9 +57,9 @@ class RoomContentForwarder(
room.use { targetRoom ->
runCatching {
// Sending a message requires a registered timeline listener
targetRoom.runWithTimelineListenerRegistered {
targetRoom.timeline().runWithTimelineListenerRegistered {
withTimeout(timeoutMs.milliseconds) {
targetRoom.send(content)
targetRoom.timeline().send(content)
}
}
}

View file

@ -57,6 +57,7 @@ import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -76,6 +77,7 @@ import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.WidgetCapabilities
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
@ -87,9 +89,10 @@ import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
class RustMatrixRoom(
override val sessionId: SessionId,
isKeyBackupEnabled: Boolean,
private val isKeyBackupEnabled: Boolean,
private val roomListItem: RoomListItem,
private val innerRoom: Room,
private val innerTimeline: Timeline,
private val roomNotificationSettingsService: RustNotificationSettingsService,
sessionCoroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
@ -130,7 +133,7 @@ class RustMatrixRoom(
override val timeline = RustMatrixTimeline(
isKeyBackupEnabled = isKeyBackupEnabled,
matrixRoom = this,
innerRoom = innerRoom,
innerTimeline = innerTimeline,
roomCoroutineScope = roomCoroutineScope,
dispatcher = roomDispatcher,
lastLoginTimestamp = sessionData.loginTimestamp,
@ -147,6 +150,7 @@ class RustMatrixRoom(
override fun destroy() {
roomCoroutineScope.cancel()
innerTimeline.destroy()
innerRoom.destroy()
roomListItem.destroy()
specialModeEventTimelineItem?.destroy()
@ -195,7 +199,7 @@ class RustMatrixRoom(
override suspend fun updateMembers(): Result<Unit> = withContext(roomMembersDispatcher) {
val currentState = _membersStateFlow.value
val currentMembers = currentState.roomMembers()
val currentMembers = currentState.roomMembers()?.toImmutableList()
_membersStateFlow.value = MatrixRoomMembersState.Pending(prevRoomMembers = currentMembers)
var rustMembers: List<RoomMember>? = null
try {
@ -210,7 +214,7 @@ class RustMatrixRoom(
}
}
val mappedMembers = rustMembers.parallelMap(RoomMemberMapper::map)
_membersStateFlow.value = MatrixRoomMembersState.Ready(mappedMembers)
_membersStateFlow.value = MatrixRoomMembersState.Ready(mappedMembers.toImmutableList())
Result.success(Unit)
} catch (exception: CancellationException) {
_membersStateFlow.value = MatrixRoomMembersState.Error(prevRoomMembers = currentMembers, failure = exception)
@ -254,7 +258,7 @@ class RustMatrixRoom(
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(roomDispatcher) {
messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content ->
runCatching {
innerRoom.send(content)
innerTimeline.send(content)
}
}
}
@ -269,9 +273,9 @@ class RustMatrixRoom(
withContext(roomDispatcher) {
if (originalEventId != null) {
runCatching {
val editedEvent = specialModeEventTimelineItem ?: innerRoom.getEventTimelineItemByEventId(originalEventId.value)
val editedEvent = specialModeEventTimelineItem ?: innerTimeline.getEventTimelineItemByEventId(originalEventId.value)
editedEvent.use {
innerRoom.edit(
innerTimeline.edit(
newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()),
editItem = it,
)
@ -281,7 +285,7 @@ class RustMatrixRoom(
} else {
runCatching {
transactionId?.let { cancelSend(it) }
innerRoom.send(messageEventContentFromParts(body, htmlBody))
innerTimeline.send(messageEventContentFromParts(body, htmlBody))
}
}
}
@ -292,15 +296,15 @@ class RustMatrixRoom(
runCatching {
specialModeEventTimelineItem?.destroy()
specialModeEventTimelineItem = null
specialModeEventTimelineItem = eventId?.let { innerRoom.getEventTimelineItemByEventId(it.value) }
specialModeEventTimelineItem = eventId?.let { innerTimeline.getEventTimelineItemByEventId(it.value) }
}
}
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(roomDispatcher) {
runCatching {
val inReplyTo = specialModeEventTimelineItem ?: innerRoom.getEventTimelineItemByEventId(eventId.value)
val inReplyTo = specialModeEventTimelineItem ?: innerTimeline.getEventTimelineItemByEventId(eventId.value)
inReplyTo.use { eventTimelineItem ->
innerRoom.sendReply(messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), eventTimelineItem)
innerTimeline.sendReply(messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), eventTimelineItem)
}
specialModeEventTimelineItem = null
}
@ -360,39 +364,45 @@ class RustMatrixRoom(
}
}
override suspend fun canUserJoinCall(userId: UserId): Result<Boolean> {
return runCatching {
innerRoom.canUserSendState(userId.value, StateEventType.ROOM_MEMBER_EVENT.map())
}
}
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
return sendAttachment(listOf(file, thumbnailFile)) {
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map(), progressCallback?.toProgressWatcher())
innerTimeline.sendImage(file.path, thumbnailFile.path, imageInfo.map(), progressCallback?.toProgressWatcher())
}
}
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
return sendAttachment(listOf(file, thumbnailFile)) {
innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map(), progressCallback?.toProgressWatcher())
innerTimeline.sendVideo(file.path, thumbnailFile.path, videoInfo.map(), progressCallback?.toProgressWatcher())
}
}
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
return sendAttachment(listOf(file)) {
innerRoom.sendAudio(file.path, audioInfo.map(), progressCallback?.toProgressWatcher())
innerTimeline.sendAudio(file.path, audioInfo.map(), progressCallback?.toProgressWatcher())
}
}
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
return sendAttachment(listOf(file)) {
innerRoom.sendFile(file.path, fileInfo.map(), progressCallback?.toProgressWatcher())
innerTimeline.sendFile(file.path, fileInfo.map(), progressCallback?.toProgressWatcher())
}
}
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.toggleReaction(key = emoji, eventId = eventId.value)
innerTimeline.toggleReaction(key = emoji, eventId = eventId.value)
}
}
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(roomDispatcher) {
runCatching {
roomContentForwarder.forward(fromRoom = innerRoom, eventId = eventId, toRoomIds = roomIds)
roomContentForwarder.forward(fromTimeline = innerTimeline, eventId = eventId, toRoomIds = roomIds)
}.onFailure {
Timber.e(it)
}
@ -400,13 +410,13 @@ class RustMatrixRoom(
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.retrySend(transactionId.value)
innerTimeline.retrySend(transactionId.value)
}
}
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.cancelSend(transactionId.value)
innerTimeline.cancelSend(transactionId.value)
}
}
@ -451,7 +461,7 @@ class RustMatrixRoom(
assetType: AssetType?,
): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.sendLocation(
innerTimeline.sendLocation(
body = body,
geoUri = geoUri,
description = description,
@ -468,7 +478,7 @@ class RustMatrixRoom(
pollKind: PollKind,
): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.createPoll(
innerTimeline.createPoll(
question = question,
answers = answers,
maxSelections = maxSelections.toUByte(),
@ -486,11 +496,11 @@ class RustMatrixRoom(
): Result<Unit> = withContext(roomDispatcher) {
runCatching {
val pollStartEvent =
innerRoom.getEventTimelineItemByEventId(
innerTimeline.getEventTimelineItemByEventId(
eventId = pollStartId.value
)
pollStartEvent.use {
innerRoom.editPoll(
innerTimeline.editPoll(
question = question,
answers = answers,
maxSelections = maxSelections.toUByte(),
@ -506,7 +516,7 @@ class RustMatrixRoom(
answers: List<String>
): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.sendPollResponse(
innerTimeline.sendPollResponse(
pollStartId = pollStartId.value,
answers = answers,
)
@ -518,7 +528,7 @@ class RustMatrixRoom(
text: String
): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.endPoll(
innerTimeline.endPoll(
pollStartId = pollStartId.value,
text = text,
)
@ -531,7 +541,7 @@ class RustMatrixRoom(
waveform: List<Float>,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendAttachment(listOf(file)) {
innerRoom.sendVoiceMessage(
innerTimeline.sendVoiceMessage(
url = file.path,
audioInfo = audioInfo.map(),
waveform = waveform.toMSC3246range(),
@ -560,7 +570,17 @@ class RustMatrixRoom(
)
}
private suspend fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
override suspend fun pollHistory() = RustMatrixTimeline(
isKeyBackupEnabled = isKeyBackupEnabled,
matrixRoom = this,
innerTimeline = innerRoom.pollHistory(),
roomCoroutineScope = roomCoroutineScope,
dispatcher = roomDispatcher,
lastLoginTimestamp = sessionData.loginTimestamp,
onNewSyncedEvent = { _syncUpdateFlow.value = systemClock.epochMillis() }
)
private fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
return runCatching {
MediaUploadHandlerImpl(files, handle())
}

View file

@ -65,7 +65,6 @@ fun RoomListInterface.loadingStateFlow(): Flow<RoomListLoadingState> =
internal fun RoomListInterface.entriesFlow(
pageSize: Int,
numberOfPages: Int,
roomListDynamicEvents: Flow<RoomListDynamicEvents>,
initialFilterKind: RoomListEntriesDynamicFilterKind
): Flow<List<RoomListEntriesUpdate>> =
@ -84,9 +83,7 @@ internal fun RoomListInterface.entriesFlow(
controller.setFilter(controllerEvents.filter)
}
is RoomListDynamicEvents.LoadMore -> {
repeat(numberOfPages) {
controller.addOnePage()
}
controller.addOnePage()
}
is RoomListDynamicEvents.Reset -> {
controller.resetToOnePage()

View file

@ -23,6 +23,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -39,57 +40,28 @@ internal class RoomListFactory(
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
) {
/**
* Creates a room list that will load all rooms in a single page.
* It mimics the usage of the old api.
*/
fun createRoomList(
innerProvider: suspend () -> InnerRoomList,
): RoomList {
return createRustRoomList(
pageSize = Int.MAX_VALUE,
numberOfPages = 1,
initialFilterKind = RoomListEntriesDynamicFilterKind.AllNonLeft,
innerRoomListProvider = innerProvider
)
}
/**
* Creates a room list that can be used to load more rooms and filter them dynamically.
*/
fun createDynamicRoomList(
pageSize: Int = DynamicRoomList.DEFAULT_PAGE_SIZE,
pagesToLoad: Int = DynamicRoomList.DEFAULT_PAGES_TO_LOAD,
initialFilter: DynamicRoomList.Filter = DynamicRoomList.Filter.None,
fun createRoomList(
pageSize: Int,
initialFilter: DynamicRoomList.Filter = DynamicRoomList.Filter.All,
innerProvider: suspend () -> InnerRoomList
): DynamicRoomList {
return createRustRoomList(
pageSize = pageSize,
numberOfPages = pagesToLoad,
initialFilterKind = initialFilter.toRustFilter(),
innerRoomListProvider = innerProvider
)
}
private fun createRustRoomList(
pageSize: Int,
numberOfPages: Int,
initialFilterKind: RoomListEntriesDynamicFilterKind,
innerRoomListProvider: suspend () -> InnerRoomList
): RustDynamicRoomList {
val loadingStateFlow: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
val summariesFlow = MutableStateFlow<List<RoomSummary>>(emptyList())
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, dispatcher, roomSummaryDetailsFactory)
val dynamicEvents = MutableSharedFlow<RoomListDynamicEvents>()
// Makes sure we don't miss any events
val dynamicEvents = MutableSharedFlow<RoomListDynamicEvents>(replay = 100)
val currentFilter = MutableStateFlow(initialFilter)
val loadedPages = MutableStateFlow(1)
var innerRoomList: InnerRoomList? = null
coroutineScope.launch(dispatcher) {
innerRoomList = innerRoomListProvider()
innerRoomList = innerProvider()
innerRoomList?.let { innerRoomList ->
innerRoomList.entriesFlow(
pageSize = pageSize,
numberOfPages = numberOfPages,
initialFilterKind = initialFilterKind,
initialFilterKind = initialFilter.toRustFilter(),
roomListDynamicEvents = dynamicEvents
).onEach { update ->
processor.postUpdate(update)
@ -105,15 +77,26 @@ internal class RoomListFactory(
}.invokeOnCompletion {
innerRoomList?.destroy()
}
return RustDynamicRoomList(summariesFlow, loadingStateFlow, dynamicEvents, processor)
return RustDynamicRoomList(
summaries = summariesFlow,
loadingState = loadingStateFlow,
currentFilter = currentFilter,
loadedPages = loadedPages,
dynamicEvents = dynamicEvents,
processor = processor,
pageSize = pageSize,
)
}
}
private class RustDynamicRoomList(
override val summaries: MutableStateFlow<List<RoomSummary>>,
override val loadingState: MutableStateFlow<RoomList.LoadingState>,
override val currentFilter: MutableStateFlow<DynamicRoomList.Filter>,
override val loadedPages: MutableStateFlow<Int>,
private val dynamicEvents: MutableSharedFlow<RoomListDynamicEvents>,
private val processor: RoomSummaryListProcessor,
override val pageSize: Int,
) : DynamicRoomList {
override suspend fun rebuildSummaries() {
@ -121,16 +104,19 @@ private class RustDynamicRoomList(
}
override suspend fun updateFilter(filter: DynamicRoomList.Filter) {
currentFilter.emit(filter)
val filterEvent = RoomListDynamicEvents.SetFilter(filter.toRustFilter())
dynamicEvents.emit(filterEvent)
}
override suspend fun loadMore() {
dynamicEvents.emit(RoomListDynamicEvents.LoadMore)
loadedPages.getAndUpdate { it + 1 }
}
override suspend fun reset() {
dynamicEvents.emit(RoomListDynamicEvents.Reset)
loadedPages.emit(1)
}
}
@ -146,6 +132,7 @@ private fun DynamicRoomList.Filter.toRustFilter(): RoomListEntriesDynamicFilterK
DynamicRoomList.Filter.All -> RoomListEntriesDynamicFilterKind.All
is DynamicRoomList.Filter.NormalizedMatchRoomName -> RoomListEntriesDynamicFilterKind.NormalizedMatchRoomName(this.pattern)
DynamicRoomList.Filter.None -> RoomListEntriesDynamicFilterKind.None
DynamicRoomList.Filter.AllNonLeft -> RoomListEntriesDynamicFilterKind.AllNonLeft
}
}

View file

@ -16,8 +16,10 @@
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -34,20 +36,31 @@ import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
import timber.log.Timber
import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService
private const val DEFAULT_PAGE_SIZE = 20
internal class RustRoomListService(
private val innerRoomListService: InnerRustRoomListService,
private val sessionCoroutineScope: CoroutineScope,
roomListFactory: RoomListFactory,
) : RoomListService {
override val allRooms: RoomList = roomListFactory.createRoomList {
override val allRooms: DynamicRoomList = roomListFactory.createRoomList(
pageSize = DEFAULT_PAGE_SIZE,
initialFilter = DynamicRoomList.Filter.AllNonLeft,
) {
innerRoomListService.allRooms()
}
override val invites: RoomList = roomListFactory.createRoomList {
override val invites: RoomList = roomListFactory.createRoomList(
pageSize = Int.MAX_VALUE,
) {
innerRoomListService.invites()
}
init {
allRooms.loadAllIncrementally(sessionCoroutineScope)
}
override fun updateAllRoomsVisibleRange(range: IntRange) {
Timber.v("setVisibleRange=$range")
sessionCoroutineScope.launch {

View file

@ -29,29 +29,28 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import org.matrix.rustcomponents.sdk.BackPaginationStatus
import org.matrix.rustcomponents.sdk.BackPaginationStatusListener
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineItem
import org.matrix.rustcomponents.sdk.TimelineListener
import timber.log.Timber
internal fun Room.timelineDiffFlow(onInitialList: suspend (List<TimelineItem>) -> Unit): Flow<List<TimelineDiff>> =
internal fun Timeline.timelineDiffFlow(onInitialList: suspend (List<TimelineItem>) -> Unit): Flow<List<TimelineDiff>> =
callbackFlow {
val listener = object : TimelineListener {
override fun onUpdate(diff: List<TimelineDiff>) {
trySendBlocking(diff)
}
}
val roomId = id()
Timber.d("Open timelineDiffFlow for room $roomId")
val result = addTimelineListener(listener)
Timber.d("Open timelineDiffFlow for TimelineInterface ${this@timelineDiffFlow}")
val result = addListener(listener)
try {
onInitialList(result.items)
} catch (exception: Exception) {
Timber.d(exception, "Catch failure in timelineDiffFlow of room $roomId")
Timber.d(exception, "Catch failure in timelineDiffFlow of TimelineInterface ${this@timelineDiffFlow}")
}
awaitClose {
Timber.d("Close timelineDiffFlow for room $roomId")
Timber.d("Close timelineDiffFlow for TimelineInterface ${this@timelineDiffFlow}")
result.itemsStream.cancelAndDestroy()
result.items.destroyAll()
}
@ -59,7 +58,7 @@ internal fun Room.timelineDiffFlow(onInitialList: suspend (List<TimelineItem>) -
Timber.d(it, "timelineDiffFlow() failed")
}.buffer(Channel.UNLIMITED)
internal fun Room.backPaginationStatusFlow(): Flow<BackPaginationStatus> =
internal fun Timeline.backPaginationStatusFlow(): Flow<BackPaginationStatus> =
mxCallbackFlow {
val listener = object : BackPaginationStatusListener {
override fun onUpdate(status: BackPaginationStatus) {
@ -71,8 +70,8 @@ internal fun Room.backPaginationStatusFlow(): Flow<BackPaginationStatus> =
}
}.buffer(Channel.UNLIMITED)
internal suspend fun Room.runWithTimelineListenerRegistered(action: suspend () -> Unit) {
val result = addTimelineListener(NoOpTimelineListener)
internal suspend fun Timeline.runWithTimelineListenerRegistered(action: suspend () -> Unit) {
val result = addListener(NoOpTimelineListener)
try {
action()
} finally {

View file

@ -46,7 +46,7 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.BackPaginationStatus
import org.matrix.rustcomponents.sdk.EventItemOrigin
import org.matrix.rustcomponents.sdk.PaginationOptions
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineItem
import timber.log.Timber
@ -59,9 +59,9 @@ class RustMatrixTimeline(
roomCoroutineScope: CoroutineScope,
isKeyBackupEnabled: Boolean,
private val matrixRoom: MatrixRoom,
private val innerRoom: Room,
private val innerTimeline: Timeline,
private val dispatcher: CoroutineDispatcher,
private val lastLoginTimestamp: Date?,
lastLoginTimestamp: Date?,
private val onNewSyncedEvent: () -> Unit,
) : MatrixTimeline {
@ -109,7 +109,7 @@ class RustMatrixTimeline(
Timber.d("Initialize timeline for room ${matrixRoom.roomId}")
roomCoroutineScope.launch(dispatcher) {
innerRoom.timelineDiffFlow { initialList ->
innerTimeline.timelineDiffFlow { initialList ->
postItems(initialList)
}.onEach { diffs ->
if (diffs.any { diff -> diff.eventOrigin() == EventItemOrigin.SYNC }) {
@ -118,7 +118,7 @@ class RustMatrixTimeline(
postDiffs(diffs)
}.launchIn(this)
innerRoom.backPaginationStatusFlow()
innerTimeline.backPaginationStatusFlow()
.onEach {
postPaginationStatus(it)
}
@ -130,7 +130,7 @@ class RustMatrixTimeline(
private suspend fun fetchMembers() = withContext(dispatcher) {
initLatch.await()
innerRoom.fetchMembers()
innerTimeline.fetchMembers()
}
@OptIn(ExperimentalCoroutinesApi::class)
@ -188,7 +188,7 @@ class RustMatrixTimeline(
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(dispatcher) {
runCatching {
innerRoom.fetchDetailsForEvent(eventId.value)
innerTimeline.fetchDetailsForEvent(eventId.value)
}
}
@ -201,7 +201,7 @@ class RustMatrixTimeline(
items = untilNumberOfItems.toUShort(),
waitForToken = true,
)
innerRoom.paginateBackwards(paginationOptions)
innerTimeline.paginateBackwards(paginationOptions)
}.onFailure { error ->
if (error is TimelineException.CannotPaginate) {
Timber.d("Can't paginate backwards on room ${matrixRoom.roomId}, we're already at the start")
@ -219,10 +219,14 @@ class RustMatrixTimeline(
override suspend fun sendReadReceipt(eventId: EventId) = withContext(dispatcher) {
runCatching {
innerRoom.sendReadReceipt(eventId = eventId.value)
innerTimeline.sendReadReceipt(eventId = eventId.value)
}
}
override fun close() {
innerTimeline.close()
}
fun getItemById(eventId: EventId): MatrixTimelineItem.Event? {
return _timelineItems.value.firstOrNull { (it as? MatrixTimelineItem.Event)?.eventId == eventId } as? MatrixTimelineItem.Event
}

View file

@ -27,6 +27,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimeli
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
@ -81,7 +84,7 @@ fun RustEventSendState?.map(): LocalEventSendState? {
}
}
private fun List<Reaction>?.map(): List<EventReaction> {
private fun List<Reaction>?.map(): ImmutableList<EventReaction> {
return this?.map {
EventReaction(
key = it.key,
@ -90,18 +93,20 @@ private fun List<Reaction>?.map(): List<EventReaction> {
senderId = UserId(sender.senderId),
timestamp = sender.timestamp.toLong()
)
}
}.toImmutableList()
)
} ?: emptyList()
}?.toImmutableList() ?: persistentListOf()
}
private fun Map<String, RustReceipt>.map(): List<Receipt> {
private fun Map<String, RustReceipt>.map(): ImmutableList<Receipt> {
return map {
Receipt(
userId = UserId(it.key),
timestamp = it.value.timestamp?.toLong() ?: 0
)
}.sortedByDescending { it.timestamp }
Receipt(
userId = UserId(it.key),
timestamp = it.value.timestamp?.toLong() ?: 0
)
}
.sortedByDescending { it.timestamp }
.toImmutableList()
}
private fun RustEventTimelineItemDebugInfo.map(): TimelineItemDebugInfo {

View file

@ -32,6 +32,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecry
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.map
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import org.matrix.rustcomponents.sdk.TimelineItemContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import org.matrix.rustcomponents.sdk.use
@ -106,10 +108,10 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap
question = kind.question,
kind = kind.kind.map(),
maxSelections = kind.maxSelections,
answers = kind.answers.map { answer -> answer.map() },
answers = kind.answers.map { answer -> answer.map() }.toImmutableList(),
votes = kind.votes.mapValues { vote ->
vote.value.map { userId -> UserId(userId) }
},
vote.value.map { userId -> UserId(userId) }.toImmutableList()
}.toImmutableMap(),
endTime = kind.endTime,
isEdited = kind.hasBeenEdited,
)

View file

@ -17,13 +17,14 @@
package io.element.android.libraries.matrix.impl.usersearch
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.SearchUsersResults
object UserSearchResultMapper {
fun map(result: SearchUsersResults): MatrixSearchUserResults {
return MatrixSearchUserResults(
results = result.results.map(UserProfileMapper::map),
results = result.results.map(UserProfileMapper::map).toImmutableList(),
limited = result.limited,
)
}

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -106,8 +107,9 @@ class RustSessionVerificationService(
override fun didReceiveVerificationData(data: List<SessionVerificationEmoji>) {
val emojis = data.map { emoji ->
emoji.use { VerificationEmoji(it.symbol(), it.description()) }
}
emoji.use { VerificationEmoji(it.symbol(), it.description()) }
}
.toImmutableList()
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojis)
}

View file

@ -28,4 +28,5 @@ dependencies {
api(libs.coroutines.core)
implementation(libs.coroutines.test)
implementation(projects.tests.testutils)
implementation(libs.kotlinx.collections.immutable)
}

View file

@ -39,7 +39,6 @@ import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.pushers.FakePushersService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@ -72,8 +71,7 @@ class FakeMatrixClient(
private var unignoreUserResult: Result<Unit> = Result.success(Unit)
private var createRoomResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var createDmResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var createDmFailure: Throwable? = null
private var findDmResult: MatrixRoom? = FakeMatrixRoom()
private var findDmResult: RoomId? = A_ROOM_ID
private var logoutFailure: Throwable? = null
private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>()
private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>()
@ -87,7 +85,7 @@ class FakeMatrixClient(
return getRoomResults[roomId]
}
override suspend fun findDM(userId: UserId): MatrixRoom? {
override suspend fun findDM(userId: UserId): RoomId? {
return findDmResult
}
@ -99,14 +97,11 @@ class FakeMatrixClient(
return unignoreUserResult
}
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> {
delay(100)
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> = simulateLongTask {
return createRoomResult
}
override suspend fun createDM(userId: UserId): Result<RoomId> {
delay(100)
createDmFailure?.let { throw it }
override suspend fun createDM(userId: UserId): Result<RoomId> = simulateLongTask {
return createDmResult
}
@ -206,11 +201,7 @@ class FakeMatrixClient(
unignoreUserResult = result
}
fun givenCreateDmError(failure: Throwable?) {
createDmFailure = failure
}
fun givenFindDmResult(result: MatrixRoom?) {
fun givenFindDmResult(result: RoomId?) {
findDmResult = result
}

View file

@ -30,7 +30,7 @@ class FakeEncryptionService : EncryptionService {
private var disableRecoveryFailure: Exception? = null
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
override val recoveryStateStateFlow: MutableStateFlow<RecoveryState> = MutableStateFlow(RecoveryState.UNKNOWN)
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Unknown)
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Starting)
private var waitForBackupUploadSteadyStateFlow: Flow<BackupUploadState> = flowOf()
private var fixRecoveryIssuesFailure: Exception? = null

View file

@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@ -110,6 +111,7 @@ class FakeMatrixRoom(
private var generateWidgetWebViewUrlResult = Result.success("https://call.element.io")
private var getWidgetDriverResult: Result<MatrixWidgetDriver> = Result.success(FakeWidgetDriver())
private var canUserTriggerRoomNotificationResult: Result<Boolean> = Result.success(true)
private var canUserJoinCallResult: Result<Boolean> = Result.success(true)
var sendMessageMentions = emptyList<Mention>()
val editMessageCalls = mutableListOf<Pair<String, String?>>()
@ -291,6 +293,10 @@ class FakeMatrixRoom(
return canUserTriggerRoomNotificationResult
}
override suspend fun canUserJoinCall(userId: UserId): Result<Boolean> {
return canUserJoinCallResult
}
override suspend fun sendImage(
file: File,
thumbnailFile: File,
@ -425,6 +431,9 @@ class FakeMatrixRoom(
): Result<String> = generateWidgetWebViewUrlResult
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult
override suspend fun pollHistory(): MatrixTimeline {
return FakeMatrixTimeline()
}
fun givenLeaveRoomError(throwable: Throwable?) {
this.leaveRoomError = throwable
@ -470,6 +479,10 @@ class FakeMatrixRoom(
canUserTriggerRoomNotificationResult = result
}
fun givenCanUserJoinCall(result: Result<Boolean>) {
canUserJoinCallResult = result
}
fun givenIgnoreResult(result: Result<Unit>) {
ignoreResult = result
}
@ -612,7 +625,7 @@ fun aRoomInfo(
isSpace = isSpace,
isTombstoned = isTombstoned,
canonicalAlias = canonicalAlias,
alternativeAliases = alternativeAliases,
alternativeAliases = alternativeAliases.toImmutableList(),
currentUserMembership = currentUserMembership,
latestEvent = latestEvent,
inviter = inviter,
@ -623,5 +636,5 @@ fun aRoomInfo(
notificationCount = notificationCount,
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = hasRoomCall,
activeRoomCallParticipants = activeRoomCallParticipants
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
)

View file

@ -44,6 +44,9 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
fun aRoomSummaryFilled(
roomId: RoomId = A_ROOM_ID,
@ -107,8 +110,8 @@ fun anEventTimelineItem(
isOwn: Boolean = false,
isRemote: Boolean = false,
localSendState: LocalEventSendState? = null,
reactions: List<EventReaction> = emptyList(),
receipts: List<Receipt> = emptyList(),
reactions: ImmutableList<EventReaction> = persistentListOf(),
receipts: ImmutableList<Receipt> = persistentListOf(),
sender: UserId = A_USER_ID,
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
timestamp: Long = 0L,
@ -181,13 +184,13 @@ fun aTimelineItemDebugInfo(
fun aPollContent(
question: String = "Do you like polls?",
answers: List<PollAnswer> = listOf(PollAnswer("1", "Yes"), PollAnswer("2", "No")),
answers: ImmutableList<PollAnswer> = persistentListOf(PollAnswer("1", "Yes"), PollAnswer("2", "No")),
) = PollContent(
question = question,
kind = PollKind.Disclosed,
maxSelections = 1u,
answers = answers,
votes = mapOf(),
votes = persistentMapOf(),
endTime = null,
isEdited = false,
)

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.test.roomlist
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
@ -54,14 +55,16 @@ class FakeRoomListService : RoomListService {
var latestSlidingSyncRange: IntRange? = null
private set
override val allRooms: RoomList = SimplePagedRoomList(
override val allRooms: DynamicRoomList = SimplePagedRoomList(
allRoomSummariesFlow,
allRoomsLoadingStateFlow,
MutableStateFlow(DynamicRoomList.Filter.None)
)
override val invites: RoomList = SimplePagedRoomList(
inviteRoomSummariesFlow,
inviteRoomsLoadingStateFlow,
MutableStateFlow(DynamicRoomList.Filter.None)
)
override fun updateAllRoomsVisibleRange(range: IntRange) {

View file

@ -19,23 +19,30 @@ package io.element.android.libraries.matrix.test.roomlist
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
data class SimplePagedRoomList(
override val summaries: StateFlow<List<RoomSummary>>,
override val loadingState: StateFlow<RoomList.LoadingState>
override val loadingState: StateFlow<RoomList.LoadingState>,
override val currentFilter: MutableStateFlow<DynamicRoomList.Filter>
) : DynamicRoomList {
override val pageSize: Int = Int.MAX_VALUE
override val loadedPages = MutableStateFlow(1)
override suspend fun loadMore() {
//No-op
loadedPages.getAndUpdate { it + 1 }
}
override suspend fun reset() {
//No-op
loadedPages.emit(1)
}
override suspend fun updateFilter(filter: DynamicRoomList.Filter) {
//No-op
currentFilter.emit(filter)
}
override suspend fun rebuildSummaries() {

View file

@ -79,4 +79,6 @@ class FakeMatrixTimeline(
sendReadReceiptLatch?.complete(Unit)
Result.success(Unit)
}
override fun close() = Unit
}

View file

@ -20,6 +20,8 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -29,7 +31,7 @@ class FakeSessionVerificationService : SessionVerificationService {
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
private var _canVerifySessionFlow = MutableStateFlow(true)
private var emojiList = emptyList<VerificationEmoji>()
private var emojiList = persistentListOf<VerificationEmoji>()
var shouldFail = false
override val verificationFlowState: StateFlow<VerificationFlowState> =_verificationFlowState
@ -87,7 +89,7 @@ class FakeSessionVerificationService : SessionVerificationService {
}
fun givenEmojiList(emojis: List<VerificationEmoji>) {
this.emojiList = emojis
this.emojiList = emojis.toPersistentList()
}
override suspend fun reset() {

View file

@ -31,6 +31,6 @@ interface MediaPreProcessor {
compressIfPossible: Boolean
): Result<MediaUploadInfo>
data class Failure(override val cause: Throwable?) : RuntimeException(cause)
data class Failure(override val cause: Throwable?) : Exception(cause)
}

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.mediaupload.api
import android.net.Uri
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
@ -39,7 +40,7 @@ class MediaSenderTests {
val sender = aMediaSender(preProcessor)
val uri = Uri.parse("content://image.jpg")
sender.sendMedia(uri = uri, mimeType = "image/jpeg", compressIfPossible = true)
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, compressIfPossible = true)
assertThat(preProcessor.processCallCount).isEqualTo(1)
}
@ -50,7 +51,7 @@ class MediaSenderTests {
val sender = aMediaSender(room = room)
val uri = Uri.parse("content://image.jpg")
sender.sendMedia(uri = uri, mimeType = "image/jpeg", compressIfPossible = true)
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, compressIfPossible = true)
assertThat(room.sendMediaCount).isEqualTo(1)
}
@ -63,7 +64,7 @@ class MediaSenderTests {
val sender = aMediaSender(preProcessor)
val uri = Uri.parse("content://image.jpg")
val result = sender.sendMedia(uri = uri, mimeType = "image/jpeg", compressIfPossible = true)
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, compressIfPossible = true)
assertThat(result.exceptionOrNull()).isNotNull()
}
@ -76,7 +77,7 @@ class MediaSenderTests {
val sender = aMediaSender(room = room)
val uri = Uri.parse("content://image.jpg")
val result = sender.sendMedia(uri = uri, mimeType = "image/jpeg", compressIfPossible = true)
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, compressIfPossible = true)
assertThat(result.exceptionOrNull()).isNotNull()
}
@ -88,7 +89,7 @@ class MediaSenderTests {
val sender = aMediaSender(room = room)
val sendJob = launch {
val uri = Uri.parse("content://image.jpg")
sender.sendMedia(uri = uri, mimeType = "image/jpeg", compressIfPossible = true)
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, compressIfPossible = true)
}
// Wait until several internal tasks run and the file is being uploaded
advanceTimeBy(3L)

View file

@ -27,6 +27,12 @@ android {
generateDaggerFactories.set(true)
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
@ -37,6 +43,7 @@ android {
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
implementation(projects.services.toolbox.api)
implementation(libs.inject)
implementation(libs.androidx.exifinterface)
implementation(libs.coroutines.core)
@ -44,7 +51,10 @@ android {
implementation(libs.vanniktech.blurhash)
testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
testImplementation(projects.tests.testutils)
testImplementation(projects.services.toolbox.test)
}
}

View file

@ -47,8 +47,9 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import java.time.Duration
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@ContributesBinding(AppScope::class)
class AndroidMediaPreProcessor @Inject constructor(
@ -269,6 +270,6 @@ fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailResult: Thumbn
private fun MediaMetadataRetriever.extractDuration(): Duration {
val durationInMs = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
return Duration.ofMillis(durationInMs)
return durationInMs.milliseconds
}

View file

@ -24,8 +24,8 @@ import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize
import io.element.android.libraries.androidutils.bitmap.resizeToMax
import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation
import io.element.android.libraries.androidutils.file.createTmpFile
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
@ -33,8 +33,8 @@ import javax.inject.Inject
class ImageCompressor @Inject constructor(
@ApplicationContext private val context: Context,
private val dispatchers: CoroutineDispatchers,
) {
/**
* Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode], then writes it into a
* temporary file using the passed [format], [orientation] and [desiredQuality].
@ -46,7 +46,7 @@ class ImageCompressor @Inject constructor(
format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG,
orientation: Int = ExifInterface.ORIENTATION_UNDEFINED,
desiredQuality: Int = 80,
): Result<ImageCompressionResult> = withContext(Dispatchers.IO) {
): Result<ImageCompressionResult> = withContext(dispatchers.io) {
runCatching {
val compressedBitmap = compressToBitmap(inputStreamProvider, resizeMode, orientation).getOrThrow()
// Encode bitmap to the destination temporary file

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.androidutils.media.runAndRelease
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import javax.inject.Inject
@ -56,13 +57,14 @@ private const val VIDEO_THUMB_FRAME = 0L
class ThumbnailFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val sdkIntProvider: BuildVersionSdkIntProvider
) {
@SuppressLint("NewApi")
suspend fun createImageThumbnail(file: File): ThumbnailResult {
return createThumbnail { cancellationSignal ->
// This API works correctly with GIF
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (sdkIntProvider.isAtLeast(Build.VERSION_CODES.Q)) {
ThumbnailUtils.createImageThumbnail(
file,
Size(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT),

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6831610b21668c49e31732f9005177e959277233d3cab758910e061294f91d79
size 687979

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a980f7b74cb9edc323919db8652798da4b3dcf865fc7b6a1eb1110096b7bfb4f
size 1856786

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0244590f2b4bcb62352b574e78bea940e8d89cfa69823b5208ef4c43e0abcb44
size 52079

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0ba904eae8773b70c75333db4de2f3ac45a8ad4ddba1b242f0b3cfc199391dd8
size 13

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fb58436524db95bd0c10b2c3023c2eb7b87404a2eab8987939f051647eb859d3
size 1673712

View file

@ -0,0 +1,347 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaupload
import android.content.Context
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.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
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.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import kotlin.time.Duration
@RunWith(RobolectricTestRunner::class)
class AndroidMediaPreProcessorTest {
@Test
fun `test processing image`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val sut = createAndroidMediaPreProcessor(context)
val file = getFileFromAssets(context, "image.png")
val result = sut.process(
uri = file.toUri(),
mimeType = MimeTypes.Png,
deleteOriginal = false,
compressIfPossible = true,
)
// This is failing for now
val error = result.exceptionOrNull()
assertThat(error).isInstanceOf(MediaPreProcessor.Failure::class.java)
assertThat(error?.cause).isInstanceOf(NullPointerException::class.java)
/*
val data = result.getOrThrow()
assertThat(data.file.path).endsWith("image.png")
val info = data as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNull() // TODO Check this
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
height = 1_178,
width = 1_818,
mimetype = MimeTypes.Png,
size = 114_867,
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
)
)
assertThat(file.exists()).isTrue()
*/
}
@Test
fun `test processing image api Q`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val sut = createAndroidMediaPreProcessor(context, sdkIntVersion = Build.VERSION_CODES.Q)
val file = getFileFromAssets(context, "image.png")
val result = sut.process(
uri = file.toUri(),
mimeType = MimeTypes.Png,
deleteOriginal = false,
compressIfPossible = true,
)
// This is not working for now
val error = result.exceptionOrNull()
assertThat(error).isInstanceOf(MediaPreProcessor.Failure::class.java)
assertThat(error?.cause).isInstanceOf(NoSuchMethodError::class.java)
/*
val data = result.getOrThrow()
assertThat(data.file.path).endsWith("image.png")
val info = data as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNull() // TODO Check this
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
height = 1_178,
width = 1_818,
mimetype = MimeTypes.Png,
size = 114_867,
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
)
)
assertThat(file.exists()).isTrue()
*/
}
@Test
fun `test processing image no compression`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val sut = createAndroidMediaPreProcessor(context)
val file = getFileFromAssets(context, "image.png")
val result = sut.process(
uri = file.toUri(),
mimeType = MimeTypes.Png,
deleteOriginal = false,
compressIfPossible = false,
).getOrThrow()
assertThat(result.file.path).endsWith("image.png")
val info = result as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNotNull()
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
height = 1_178,
width = 1_818,
mimetype = MimeTypes.Png,
size = 1_856_786,
thumbnailInfo = ThumbnailInfo(height = 25, width = 25, mimetype = MimeTypes.Jpeg, size = 643),
thumbnailSource = null,
blurhash = "K00000fQfQfQfQfQfQfQfQ",
)
)
assertThat(file.exists()).isTrue()
}
@Test
fun `test processing image and delete`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val sut = createAndroidMediaPreProcessor(context)
val file = getFileFromAssets(context, "image.png")
val result = sut.process(
uri = file.toUri(),
mimeType = MimeTypes.Png,
deleteOriginal = true,
compressIfPossible = false,
).getOrThrow()
assertThat(result.file.path).endsWith("image.png")
val info = result as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNotNull()
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
height = 1_178,
width = 1_818,
mimetype = MimeTypes.Png,
size = 1_856_786,
thumbnailInfo = ThumbnailInfo(height = 25, width = 25, mimetype = MimeTypes.Jpeg, size = 643),
thumbnailSource = null,
blurhash = "K00000fQfQfQfQfQfQfQfQ",
)
)
// Does not work
// assertThat(file.exists()).isFalse()
}
@Test
fun `test processing gif`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val sut = createAndroidMediaPreProcessor(context)
val file = getFileFromAssets(context, "animated_gif.gif")
val result = sut.process(
uri = file.toUri(),
mimeType = MimeTypes.Gif,
deleteOriginal = false,
compressIfPossible = true,
).getOrThrow()
assertThat(result.file.path).endsWith("animated_gif.gif")
val info = result as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNotNull()
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
height = 600,
width = 800,
mimetype = MimeTypes.Gif,
size = 687_979,
thumbnailInfo = ThumbnailInfo(height = 50, width = 50, mimetype = MimeTypes.Jpeg, size = 691),
thumbnailSource = null,
blurhash = "K00000fQfQfQfQfQfQfQfQ",
)
)
assertThat(file.exists()).isTrue()
}
@Test
fun `test processing file`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val sut = createAndroidMediaPreProcessor(context)
val file = getFileFromAssets(context, "text.txt")
val result = sut.process(
uri = file.toUri(),
mimeType = MimeTypes.PlainText,
deleteOriginal = false,
compressIfPossible = true,
).getOrThrow()
assertThat(result.file.path).endsWith("text.txt")
val info = result as MediaUploadInfo.AnyFile
assertThat(info.fileInfo).isEqualTo(
FileInfo(
mimetype = MimeTypes.PlainText,
size = 13,
thumbnailInfo = null,
thumbnailSource = null,
)
)
assertThat(file.exists()).isTrue()
}
@Ignore("Compressing video is not working with Robolectric")
@Test
fun `test processing video`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val sut = createAndroidMediaPreProcessor(context)
val file = getFileFromAssets(context, "video.mp4")
val result = sut.process(
uri = file.toUri(),
mimeType = MimeTypes.Mp4,
deleteOriginal = false,
compressIfPossible = true,
).getOrThrow()
assertThat(result.file.path).endsWith("video.mp4")
val info = result as MediaUploadInfo.Video
assertThat(info.thumbnailFile).isNotNull()
assertThat(info.videoInfo).isEqualTo(
VideoInfo(
duration = Duration.ZERO, // Not available with Robolectric?
height = 1_178,
width = 1_818,
mimetype = MimeTypes.Mp4,
size = 114_867,
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
)
)
assertThat(file.exists()).isTrue()
}
@Test
fun `test processing video no compression`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val sut = createAndroidMediaPreProcessor(context)
val file = getFileFromAssets(context, "video.mp4")
val result = sut.process(
uri = file.toUri(),
mimeType = MimeTypes.Mp4,
deleteOriginal = false,
compressIfPossible = false,
).getOrThrow()
assertThat(result.file.path).endsWith("video.mp4")
val info = result as MediaUploadInfo.Video
assertThat(info.thumbnailFile).isNotNull()
assertThat(info.videoInfo).isEqualTo(
VideoInfo(
duration = Duration.ZERO, // Not available with Robolectric?
height = 0, // Not available with Robolectric?
width = 0, // Not available with Robolectric?
mimetype = MimeTypes.Mp4,
size = 1_673_712,
thumbnailInfo = ThumbnailInfo(height = null, width = null, mimetype = MimeTypes.Jpeg, size = 0), // Not available with Robolectric?
thumbnailSource = null,
blurhash = null,
)
)
assertThat(file.exists()).isTrue()
}
@Test
fun `test processing audio`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val sut = createAndroidMediaPreProcessor(context)
val file = getFileFromAssets(context, "sample3s.mp3")
val result = sut.process(
uri = file.toUri(),
mimeType = MimeTypes.Mp3,
deleteOriginal = false,
compressIfPossible = true,
).getOrThrow()
assertThat(result.file.path).endsWith("sample3s.mp3")
val info = result as MediaUploadInfo.Audio
assertThat(info.audioInfo).isEqualTo(
AudioInfo(
duration = Duration.ZERO, // Not available with Robolectric?
size = 52_079,
mimetype = MimeTypes.Mp3,
)
)
assertThat(file.exists()).isTrue()
}
@Test
fun `test file which does not exist`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val sut = createAndroidMediaPreProcessor(context)
val file = File(context.cacheDir, "not found.txt")
val result = sut.process(
uri = file.toUri(),
mimeType = MimeTypes.PlainText,
deleteOriginal = false,
compressIfPossible = true,
)
assertThat(result.isFailure).isTrue()
val failure = result.exceptionOrNull()
assertThat(failure).isInstanceOf(MediaPreProcessor.Failure::class.java)
assertThat(failure?.cause).isInstanceOf(FileNotFoundException::class.java)
}
private fun TestScope.createAndroidMediaPreProcessor(
context: Context,
sdkIntVersion: Int = Build.VERSION_CODES.P
) = AndroidMediaPreProcessor(
context = context,
thumbnailFactory = ThumbnailFactory(context, FakeBuildVersionSdkIntProvider(sdkIntVersion)),
imageCompressor = ImageCompressor(context, testCoroutineDispatchers()),
videoCompressor = VideoCompressor(context),
coroutineDispatchers = testCoroutineDispatchers(),
)
@Throws(IOException::class)
private fun getFileFromAssets(context: Context, fileName: String): File = File(context.cacheDir, fileName)
.also {
if (!it.exists()) {
it.outputStream().use { cache ->
context.assets.open(fileName).use { inputStream ->
inputStream.copyTo(cache)
}
}
}
}
}

View file

@ -24,5 +24,6 @@ android {
dependencies {
api(projects.libraries.mediaupload.api)
implementation(projects.libraries.core)
implementation(projects.tests.testutils)
}

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.mediaupload.test
import android.net.Uri
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
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
@ -24,7 +25,6 @@ import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.tests.testutils.simulateLongTask
import java.io.File
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
class FakeMediaPreProcessor : MediaPreProcessor {
@ -35,7 +35,7 @@ class FakeMediaPreProcessor : MediaPreProcessor {
MediaUploadInfo.AnyFile(
File("test"),
FileInfo(
mimetype = "*/*",
mimetype = MimeTypes.Any,
size = 999L,
thumbnailInfo = null,
thumbnailSource = null,
@ -63,9 +63,9 @@ class FakeMediaPreProcessor : MediaPreProcessor {
MediaUploadInfo.Audio(
file = File("audio.ogg"),
audioInfo = AudioInfo(
duration = 1000.seconds.toJavaDuration(),
duration = 1000.seconds,
size = 1000,
mimetype = "audio/ogg",
mimetype = MimeTypes.Ogg,
),
)
)

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.libraries.mediaviewer.api"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
anvil(projects.anvilcodegen)
implementation(projects.anvilannotations)
implementation(libs.coil.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.coroutines.core)
implementation(libs.dagger)
implementation(libs.telephoto.zoomableimage)
implementation(libs.vanniktech.blurhash)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
ksp(libs.showkase.processor)
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.helper
fun formatFileExtensionAndSize(extension: String, size: String?): String {
return buildString {
append(extension.uppercase())
if (size != null) {
append(' ')
append("($size)")
}
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local
import android.net.Uri
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize
@Parcelize
@Immutable
data class LocalMedia(
val uri: Uri,
val info: MediaInfo,
) : Parcelable

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local
import androidx.compose.runtime.Composable
interface LocalMediaActions {
@Composable
fun Configure()
/**
* Will save the current media to the Downloads directory.
* The [LocalMedia.uri] needs to have a file scheme.
*/
suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit>
/**
* Will try to find a suitable application to share the media with.
* The [LocalMedia.uri] needs to have a file scheme.
*/
suspend fun share(localMedia: LocalMedia): Result<Unit>
/**
* Will try to find a suitable application to open the media with.
* The [LocalMedia.uri] needs to have a file scheme.
*/
suspend fun open(localMedia: LocalMedia): Result<Unit>
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local
import android.net.Uri
import io.element.android.libraries.matrix.api.media.MediaFile
interface LocalMediaFactory {
/**
* This method will create a [LocalMedia] with the given [MediaFile] and [MediaInfo].
*/
fun createFromMediaFile(
mediaFile: MediaFile,
mediaInfo: MediaInfo,
): LocalMedia
/**
* This method will create a [LocalMedia] with the given mimeType, name and formattedFileSize
* If any of those params are null, it'll try to read them from the content.
*/
fun createFromUri(
uri: Uri,
mimeType: String?,
name: String?,
formattedFileSize: String?
): LocalMedia
}

View file

@ -0,0 +1,270 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local
import android.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.mediaviewer.api.local.exoplayer.ExoPlayerWrapper
import io.element.android.libraries.mediaviewer.api.local.pdf.PdfViewer
import io.element.android.libraries.mediaviewer.api.local.pdf.rememberPdfViewerState
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
import io.element.android.libraries.ui.strings.CommonStrings
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.ZoomableState
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
import me.saket.telephoto.zoomable.rememberZoomableState
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun LocalMediaView(
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
mediaInfo: MediaInfo? = localMedia?.info,
) {
val zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 5f)
)
val mimeType = mediaInfo?.mimeType
when {
mimeType.isMimeTypeImage() -> MediaImageView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
zoomableState = zoomableState,
modifier = modifier
)
mimeType.isMimeTypeVideo() -> MediaVideoView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier
)
mimeType == MimeTypes.Pdf -> MediaPDFView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
zoomableState = zoomableState,
modifier = modifier
)
//TODO handle audio with exoplayer
else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,
info = mediaInfo,
modifier = modifier
)
}
}
@Composable
private fun MediaImageView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
zoomableState: ZoomableState,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = modifier.fillMaxSize(),
contentDescription = null,
)
} else {
val zoomableImageState = rememberZoomableImageState(zoomableState)
localMediaViewState.isReady = zoomableImageState.isImageDisplayed
ZoomableAsyncImage(
modifier = modifier.fillMaxSize(),
state = zoomableImageState,
model = localMedia?.uri,
contentDescription = stringResource(id = CommonStrings.common_image),
contentScale = ContentScale.Fit,
)
}
}
@UnstableApi
@Composable
private fun MediaVideoView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val playerListener = object : Player.Listener {
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
localMediaViewState.isPlaying = isPlaying
}
}
val exoPlayer = remember {
ExoPlayerWrapper.create(context)
.apply {
addListener(playerListener)
this.prepare()
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
}
} else {
exoPlayer.setMediaItems(emptyList())
}
KeepScreenOn(localMediaViewState.isPlaying)
AndroidView(
factory = {
PlayerView(context).apply {
player = exoPlayer
setShowPreviousButton(false)
setShowNextButton(false)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
controllerShowTimeoutMs = 3000
}
},
modifier = modifier.fillMaxSize()
)
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> exoPlayer.play()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
exoPlayer.removeListener(playerListener)
}
else -> Unit
}
}
}
@Composable
private fun MediaPDFView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
zoomableState: ZoomableState,
modifier: Modifier = Modifier,
) {
val pdfViewerState = rememberPdfViewerState(
model = localMedia?.uri,
zoomableState = zoomableState
)
localMediaViewState.isReady = pdfViewerState.isLoaded
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier)
}
@Composable
private fun MediaFileView(
localMediaViewState: LocalMediaViewState,
uri: Uri?,
info: MediaInfo?,
modifier: Modifier = Modifier,
) {
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
localMediaViewState.isReady = uri != null
Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (isAudio) Icons.Outlined.GraphicEq else null,
resourceId = if (isAudio) null else CommonDrawables.ic_attachment,
contentDescription = null,
tint = MaterialTheme.colorScheme.background,
modifier = Modifier
.size(32.dp)
.rotate(if (isAudio) 0f else -45f),
)
}
if (info != null) {
Spacer(modifier = Modifier.height(20.dp))
Text(
text = info.name,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize),
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Stable
class LocalMediaViewState {
var isReady: Boolean by mutableStateOf(false)
var isPlaying: Boolean by mutableStateOf(false)
}
@Composable
fun rememberLocalMediaViewState(): LocalMediaViewState {
return remember {
LocalMediaViewState()
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local
import android.os.Parcelable
import io.element.android.libraries.core.mimetype.MimeTypes
import kotlinx.parcelize.Parcelize
@Parcelize
data class MediaInfo(
val name: String,
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
) : Parcelable
fun anImageInfo(): MediaInfo = MediaInfo(
"an image file.jpg", MimeTypes.Jpeg, "4MB", "jpg"
)
fun aVideoInfo(): MediaInfo = MediaInfo(
"a video file.mp4", MimeTypes.Mp4, "14MB", "mp4"
)
fun aPdfInfo(): MediaInfo = MediaInfo(
"a pdf file.pdf", MimeTypes.Pdf, "23MB", "pdf"
)
fun aFileInfo(): MediaInfo = MediaInfo(
"an apk file.apk", MimeTypes.Apk, "50MB", "apk"
)
fun anAudioInfo(): MediaInfo = MediaInfo(
"an audio file.mp3", MimeTypes.Mp3, "7MB", "mp3"
)

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local.exoplayer
import android.content.Context
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
/**
* Wrapper around ExoPlayer to disable some commands.
* Necessary to hide the settings wheels from the player.
*/
@UnstableApi
class ExoPlayerWrapper(private val exoPlayer: ExoPlayer) : ExoPlayer by exoPlayer {
override fun isCommandAvailable(command: Int): Boolean {
return availableCommands.contains(command)
}
override fun getAvailableCommands(): Player.Commands {
return exoPlayer.availableCommands
.buildUpon()
.remove(Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS)
.build()
}
companion object {
fun create(context: Context): ExoPlayer {
return ExoPlayerWrapper(
ExoPlayer.Builder(context).build()
)
}
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local.pdf
import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.File
class ParcelFileDescriptorFactory(private val context: Context) {
fun create(model: Any?) = runCatching {
when (model) {
is File -> ParcelFileDescriptor.open(model, ParcelFileDescriptor.MODE_READ_ONLY)
is Uri -> context.contentResolver.openFileDescriptor(model, "r")!!
else -> error(RuntimeException("Can't handle this model"))
}
}
}

View file

@ -0,0 +1,111 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local.pdf
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import androidx.compose.runtime.Stable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@Stable
class PdfPage(
maxWidth: Int,
val pageIndex: Int,
private val mutex: Mutex,
private val pdfRenderer: PdfRenderer,
private val coroutineScope: CoroutineScope,
) {
sealed interface State {
data class Loading(val width: Int, val height: Int) : State
data class Loaded(val bitmap: Bitmap) : State
}
private val renderWidth = maxWidth
private val renderHeight: Int
private var loadJob: Job? = null
init {
// We are just opening and closing the page to extract data so we can build the Loading state with the correct dimensions.
pdfRenderer.openPage(pageIndex).use { page ->
renderHeight = (page.height * (renderWidth.toFloat() / page.width)).toInt()
}
}
private val mutableStateFlow = MutableStateFlow<State>(
State.Loading(
width = renderWidth,
height = renderHeight
)
)
val stateFlow: StateFlow<State> = mutableStateFlow
fun load() {
loadJob = coroutineScope.launch {
val bitmap = mutex.withLock {
withContext(Dispatchers.IO) {
pdfRenderer.openPageRenderAndClose(pageIndex, renderWidth, renderHeight)
}
}
mutableStateFlow.value = State.Loaded(bitmap)
}
}
fun close() {
loadJob?.cancel()
when (val loadingState = stateFlow.value) {
is State.Loading -> return
is State.Loaded -> {
loadingState.bitmap.recycle()
mutableStateFlow.value = State.Loading(
width = renderWidth,
height = renderHeight
)
}
}
}
private fun PdfRenderer.openPageRenderAndClose(index: Int, bitmapWidth: Int, bitmapHeight: Int): Bitmap {
fun createBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap {
val bitmap = Bitmap.createBitmap(
bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
canvas.drawBitmap(bitmap, 0f, 0f, null)
return bitmap
}
return openPage(index).use { page ->
createBitmap(bitmapWidth, bitmapHeight).apply {
page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
}
}
}
}

View file

@ -0,0 +1,74 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local.pdf
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class PdfRendererManager(
private val parcelFileDescriptor: ParcelFileDescriptor,
private val width: Int,
private val coroutineScope: CoroutineScope,
) {
private val mutex = Mutex()
private var pdfRenderer: PdfRenderer? = null
private val mutablePdfPages = MutableStateFlow<List<PdfPage>>(emptyList())
val pdfPages: StateFlow<List<PdfPage>> = mutablePdfPages
fun open() {
coroutineScope.launch {
mutex.withLock {
withContext(Dispatchers.IO) {
pdfRenderer = PdfRenderer(parcelFileDescriptor).apply {
// Preload just 3 pages so we can render faster
val firstPages = loadPages(from = 0, to = 3)
mutablePdfPages.value = firstPages
val nextPages = loadPages(from = 3, to = pageCount)
mutablePdfPages.value = firstPages + nextPages
}
}
}
}
}
fun close() {
coroutineScope.launch {
mutex.withLock {
mutablePdfPages.value.forEach { pdfPage ->
pdfPage.close()
}
pdfRenderer?.close()
parcelFileDescriptor.close()
}
}
}
private fun PdfRenderer.loadPages(from: Int, to: Int): List<PdfPage> {
return (from until minOf(to, pageCount)).map { pageIndex ->
PdfPage(width, pageIndex, mutex, this, coroutineScope)
}
}
}

View file

@ -0,0 +1,117 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local.pdf
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import me.saket.telephoto.zoomable.zoomable
@Composable
fun PdfViewer(
pdfViewerState: PdfViewerState,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(
modifier = modifier.zoomable(pdfViewerState.zoomableState),
contentAlignment = Alignment.Center
) {
val maxWidthInPx = maxWidth.roundToPx()
DisposableEffect(pdfViewerState) {
pdfViewerState.openForWidth(maxWidthInPx)
onDispose {
pdfViewerState.close()
}
}
val pdfPages = pdfViewerState.getPages()
PdfPagesView(pdfPages.toImmutableList(), pdfViewerState.lazyListState)
}
}
@Composable
private fun PdfPagesView(
pdfPages: ImmutableList<PdfPage>,
lazyListState: LazyListState,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically)
) {
items(pdfPages.size) { index ->
val pdfPage = pdfPages[index]
PdfPageView(pdfPage)
}
}
}
@Composable
private fun PdfPageView(
pdfPage: PdfPage,
modifier: Modifier = Modifier,
) {
val pdfPageState by pdfPage.stateFlow.collectAsState()
DisposableEffect(pdfPage) {
pdfPage.load()
onDispose {
pdfPage.close()
}
}
when (val state = pdfPageState) {
is PdfPage.State.Loaded -> {
Image(
bitmap = state.bitmap.asImageBitmap(),
contentDescription = stringResource(id = CommonStrings.a11y_page_n, pdfPage.pageIndex),
contentScale = ContentScale.FillWidth,
modifier = modifier.fillMaxWidth()
)
}
is PdfPage.State.Loading -> {
Box(
modifier = modifier
.fillMaxWidth()
.height(state.height.toDp())
.background(color = Color.White)
)
}
}
}

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.local.pdf
import android.content.Context
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.CoroutineScope
import me.saket.telephoto.zoomable.ZoomableState
import me.saket.telephoto.zoomable.rememberZoomableState
@Stable
class PdfViewerState(
private val model: Any?,
private val coroutineScope: CoroutineScope,
private val context: Context,
val zoomableState: ZoomableState,
val lazyListState: LazyListState,
) {
var isLoaded by mutableStateOf(false)
private var pdfRendererManager by mutableStateOf<PdfRendererManager?>(null)
@Composable
fun getPages(): List<PdfPage>{
return pdfRendererManager?.run {
pdfPages.collectAsState().value
}?: emptyList()
}
fun openForWidth(maxWidth: Int) {
ParcelFileDescriptorFactory(context).create(model)
.onSuccess {
pdfRendererManager = PdfRendererManager(it, maxWidth, coroutineScope).apply {
open()
}
isLoaded = true
}
}
fun close() {
pdfRendererManager?.close()
isLoaded = false
}
}
@Composable
fun rememberPdfViewerState(
model: Any?,
zoomableState: ZoomableState = rememberZoomableState(),
lazyListState: LazyListState = rememberLazyListState(),
context: Context = LocalContext.current,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
): PdfViewerState {
return remember(model) {
PdfViewerState(
model = model,
coroutineScope = coroutineScope,
context = context,
zoomableState = zoomableState,
lazyListState = lazyListState
)
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.util
import android.webkit.MimeTypeMap
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
interface FileExtensionExtractor {
fun extractFromName(name: String): String
}
@ContributesBinding(AppScope::class)
class FileExtensionExtractorWithValidation @Inject constructor() : FileExtensionExtractor {
override fun extractFromName(name: String): String {
val fileExtension = name.substringAfterLast('.', "")
// Makes sure the extension is known by the system, otherwise default to binary extension.
return if (MimeTypeMap.getSingleton().hasExtension(fileExtension)) {
fileExtension
} else {
"bin"
}
}
}
class FileExtensionExtractorWithoutValidation : FileExtensionExtractor {
override fun extractFromName(name: String): String {
return name.substringAfterLast('.', "")
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.viewer
sealed interface MediaViewerEvents {
data object SaveOnDisk: MediaViewerEvents
data object Share: MediaViewerEvents
data object OpenWith: MediaViewerEvents
data object RetryLoading : MediaViewerEvents
data object ClearLoadingError : MediaViewerEvents
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.viewer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
@ContributesNode(RoomScope::class)
open class MediaViewerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canDownload: Boolean,
val canShare: Boolean,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs)
@Composable
override fun View(modifier: Modifier) {
ForcedDarkElementTheme {
val state = presenter.present()
MediaViewerView(
state = state,
modifier = modifier,
onBackPressed = this::navigateUp
)
}
}
}

View file

@ -0,0 +1,168 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.viewer
import android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaActions
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR
class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerNode.Inputs,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MediaViewerState> {
@AssistedFactory
interface Factory {
fun create(inputs: MediaViewerNode.Inputs): MediaViewerPresenter
}
@Composable
override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope()
var loadMediaTrigger by remember { mutableIntStateOf(0) }
val mediaFile: MutableState<MediaFile?> = remember {
mutableStateOf(null)
}
val localMedia: MutableState<Async<LocalMedia>> = remember {
mutableStateOf(Async.Uninitialized)
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
DisposableEffect(loadMediaTrigger) {
coroutineScope.downloadMedia(mediaFile, localMedia)
onDispose {
mediaFile.value?.close()
}
}
fun handleEvents(mediaViewerEvents: MediaViewerEvents) {
when (mediaViewerEvents) {
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized
MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value)
MediaViewerEvents.Share -> coroutineScope.share(localMedia.value)
MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value)
}
}
return MediaViewerState(
mediaInfo = inputs.mediaInfo,
thumbnailSource = inputs.thumbnailSource,
downloadedMedia = localMedia.value,
snackbarMessage = snackbarMessage,
canDownload = inputs.canDownload,
canShare = inputs.canShare,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch {
localMedia.value = Async.Loading()
mediaLoader.downloadMediaFile(
source = inputs.mediaSource,
mimeType = inputs.mediaInfo.mimeType,
body = inputs.mediaInfo.name
)
.onSuccess {
mediaFile.value = it
}
.mapCatching { mediaFile ->
localMediaFactory.createFromMediaFile(
mediaFile = mediaFile,
mediaInfo = inputs.mediaInfo
)
}
.onSuccess {
localMedia.value = Async.Success(it)
}
.onFailure {
localMedia.value = Async.Failure(it)
}
}
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) {
localMediaActions.saveOnDisk(localMedia.data)
.onSuccess {
val snackbarMessage = SnackbarMessage(CommonStrings.common_file_saved_on_disk_android)
snackbarDispatcher.post(snackbarMessage)
}
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
} else Unit
}
private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) {
localMediaActions.share(localMedia.data)
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
} else Unit
}
private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) {
localMediaActions.open(localMedia.data)
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
} else Unit
}
private fun mediaActionsError(throwable: Throwable): Int {
return if (throwable is ActivityNotFoundException) {
UtilsR.string.error_no_compatible_app_found
} else {
CommonStrings.error_unknown
}
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediaviewer.api.viewer
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
data class MediaViewerState(
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
val downloadedMedia: Async<LocalMedia>,
val snackbarMessage: SnackbarMessage?,
val canDownload: Boolean,
val canShare: Boolean,
val eventSink: (MediaViewerEvents) -> Unit,
)

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