Merge branch 'develop' into jonny/timeline-poll-edited
This commit is contained in:
commit
f6ec76b5ef
602 changed files with 5298 additions and 1508 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 != "*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>? {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,4 +28,5 @@ dependencies {
|
|||
api(libs.coroutines.core)
|
||||
implementation(libs.coroutines.test)
|
||||
implementation(projects.tests.testutils)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -79,4 +79,6 @@ class FakeMatrixTimeline(
|
|||
sendReadReceiptLatch?.complete(Unit)
|
||||
Result.success(Unit)
|
||||
}
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6831610b21668c49e31732f9005177e959277233d3cab758910e061294f91d79
|
||||
size 687979
|
||||
3
libraries/mediaupload/impl/src/test/assets/image.png
Normal file
3
libraries/mediaupload/impl/src/test/assets/image.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a980f7b74cb9edc323919db8652798da4b3dcf865fc7b6a1eb1110096b7bfb4f
|
||||
size 1856786
|
||||
3
libraries/mediaupload/impl/src/test/assets/sample3s.mp3
Normal file
3
libraries/mediaupload/impl/src/test/assets/sample3s.mp3
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0244590f2b4bcb62352b574e78bea940e8d89cfa69823b5208ef4c43e0abcb44
|
||||
size 52079
|
||||
3
libraries/mediaupload/impl/src/test/assets/text.txt
Normal file
3
libraries/mediaupload/impl/src/test/assets/text.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0ba904eae8773b70c75333db4de2f3ac45a8ad4ddba1b242f0b3cfc199391dd8
|
||||
size 13
|
||||
3
libraries/mediaupload/impl/src/test/assets/video.mp4
Normal file
3
libraries/mediaupload/impl/src/test/assets/video.mp4
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fb58436524db95bd0c10b2c3023c2eb7b87404a2eab8987939f051647eb859d3
|
||||
size 1673712
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,5 +24,6 @@ android {
|
|||
|
||||
dependencies {
|
||||
api(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
64
libraries/mediaviewer/api/build.gradle.kts
Normal file
64
libraries/mediaviewer/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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('.', "")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue