Introduce Attachments and LocalMedia (WIP)

This commit is contained in:
ganfra 2023-05-05 19:48:50 +02:00
parent 72dea2f817
commit 266d48b48b
25 changed files with 634 additions and 119 deletions

View file

@ -50,6 +50,9 @@ dependencies {
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerview)
implementation(libs.jsoup) implementation(libs.jsoup)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.accompanist.systemui)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

View file

@ -26,18 +26,23 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MatrixMediaSource
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class MessagesFlowNode @AssistedInject constructor( class MessagesFlowNode @AssistedInject constructor(
@ -57,7 +62,10 @@ class MessagesFlowNode @AssistedInject constructor(
object Messages : NavTarget object Messages : NavTarget
@Parcelize @Parcelize
data class MediaViewer(val mediaContent: MediaContentUiModel) : NavTarget data class MediaViewer(val title: String, val mediaSource: MatrixMediaSource) : NavTarget
@Parcelize
data class AttachmentPreview(val attachment: Attachment) : NavTarget
} }
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull() private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
@ -73,25 +81,35 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onEventClicked(event: TimelineItem.Event) { override fun onEventClicked(event: TimelineItem.Event) {
processEventClicked(event) processEventClicked(event)
} }
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
backstack.push(NavTarget.AttachmentPreview(attachments.first()))
}
} }
createNode<MessagesNode>(buildContext, listOf(callback)) createNode<MessagesNode>(buildContext, listOf(callback))
} }
is NavTarget.MediaViewer -> { is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs(navTarget.mediaContent) val inputs = MediaViewerNode.Inputs(navTarget.title, navTarget.mediaSource)
createNode<MediaViewerNode>(buildContext, listOf(inputs)) createNode<MediaViewerNode>(buildContext, listOf(inputs))
} }
is NavTarget.AttachmentPreview -> {
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
}
} }
} }
private fun processEventClicked(event: TimelineItem.Event) { private fun processEventClicked(event: TimelineItem.Event) {
when (event.content) { when (event.content) {
is TimelineItemImageContent -> { is TimelineItemImageContent -> {
val mediaContent = MediaContentUiModel.Image( val mediaSource = event.content.mediaSource
body = event.content.body, val navTarget = NavTarget.MediaViewer(event.content.body, mediaSource)
url = event.content.mediaRequestData.url, backstack.push(navTarget)
blurhash = event.content.blurhash }
) is TimelineItemVideoContent -> {
backstack.push(NavTarget.MediaViewer(mediaContent)) val mediaSource = event.content.videoSource
val navTarget = NavTarget.MediaViewer(event.content.body, mediaSource)
backstack.push(navTarget)
} }
else -> Unit else -> Unit
} }
@ -102,6 +120,7 @@ class MessagesFlowNode @AssistedInject constructor(
Children( Children(
navModel = backstack, navModel = backstack,
modifier = modifier, modifier = modifier,
transitionHandler = rememberBackstackFader()
) )
} }
} }

View file

@ -25,8 +25,10 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class MessagesNode @AssistedInject constructor( class MessagesNode @AssistedInject constructor(
@ -40,6 +42,7 @@ class MessagesNode @AssistedInject constructor(
interface Callback : Plugin { interface Callback : Plugin {
fun onRoomDetailsClicked() fun onRoomDetailsClicked()
fun onEventClicked(event: TimelineItem.Event) fun onEventClicked(event: TimelineItem.Event)
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
} }
private fun onRoomDetailsClicked() { private fun onRoomDetailsClicked() {
@ -50,6 +53,10 @@ class MessagesNode @AssistedInject constructor(
callback?.onEventClicked(event) callback?.onEventClicked(event)
} }
private fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
callback?.onPreviewAttachments(attachments)
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -58,6 +65,7 @@ class MessagesNode @AssistedInject constructor(
onBackPressed = this::navigateUp, onBackPressed = this::navigateUp,
onRoomDetailsClicked = this::onRoomDetailsClicked, onRoomDetailsClicked = this::onRoomDetailsClicked,
onEventClicked = this::onEventClicked, onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments,
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -62,7 +62,9 @@ import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
import io.element.android.features.messages.impl.textcomposer.AttachmentsState
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
import io.element.android.features.messages.impl.textcomposer.MessageComposerView import io.element.android.features.messages.impl.textcomposer.MessageComposerView
import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.TimelineView
@ -80,6 +82,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.LogCompositions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -89,6 +92,7 @@ fun MessagesView(
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
onRoomDetailsClicked: () -> Unit, onRoomDetailsClicked: () -> Unit,
onEventClicked: (event: TimelineItem.Event) -> Unit, onEventClicked: (event: TimelineItem.Event) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LogCompositions(tag = "MessagesScreen", msg = "Root") LogCompositions(tag = "MessagesScreen", msg = "Root")
@ -105,6 +109,13 @@ fun MessagesView(
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState) val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val attachmentsState = state.composerState.attachmentsState
if (attachmentsState is AttachmentsState.Previewing) {
LaunchedEffect(attachmentsState) {
onPreviewAttachments(attachmentsState.attachments)
}
}
BackHandler(enabled = bottomSheetState.isVisible) { BackHandler(enabled = bottomSheetState.isVisible) {
coroutineScope.launch { coroutineScope.launch {
bottomSheetState.hide() bottomSheetState.hide()
@ -327,5 +338,5 @@ internal fun MessagesViewDarkPreview(@PreviewParameter(MessagesStateProvider::cl
@Composable @Composable
private fun ContentToPreview(state: MessagesState) { private fun ContentToPreview(state: MessagesState) {
MessagesView(state, {}, {}, {}) MessagesView(state, {}, {}, {}, {})
} }

View file

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

View file

@ -0,0 +1,53 @@
/*
* 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.features.messages.impl.attachments.preview
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.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
class AttachmentsPreviewNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: AttachmentsPreviewPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(val attachment: Attachment) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.attachment)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
AttachmentsPreviewView(
state = state,
modifier = modifier
)
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Presenter
class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment,
) : Presenter<AttachmentsPreviewState> {
@AssistedFactory
interface Factory {
fun create(attachment: Attachment): AttachmentsPreviewPresenter
}
@Composable
override fun present(): AttachmentsPreviewState {
return AttachmentsPreviewState(
attachment = attachment,
)
}
}

View file

@ -0,0 +1,23 @@
/*
* 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.features.messages.impl.attachments.preview
import io.element.android.features.messages.impl.attachments.Attachment
data class AttachmentsPreviewState(
val attachment: Attachment,
)

View file

@ -0,0 +1,36 @@
/*
* 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.features.messages.impl.attachments.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState>
get() = sequenceOf(
aAttachmentsPreviewState(),
// Add other states here
)
}
fun aAttachmentsPreviewState() = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("".toUri(), mimeType = null)
)
)

View file

@ -0,0 +1,66 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Scaffold
@Composable
fun AttachmentsPreviewView(
state: AttachmentsPreviewState,
modifier: Modifier = Modifier,
) {
Scaffold(modifier) {
Box(
modifier = Modifier,
contentAlignment = Alignment.Center
) {
when (state.attachment) {
is Attachment.Media -> LocalMediaView(localMedia = state.attachment.localMedia)
}
}
}
}
@Preview
@Composable
fun AttachmentsPreviewViewLightPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun AttachmentsPreviewViewDarkPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: AttachmentsPreviewState) {
AttachmentsPreviewView(
state = state,
)
}

View file

@ -0,0 +1,36 @@
/*
* 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.features.messages.impl.media.local
import android.content.Context
import android.net.Uri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidLocalMediaFactory @Inject constructor(
@ApplicationContext private val context: Context
) : LocalMediaFactory {
override fun createFromUri(uri: Uri?, mimeType: String?): LocalMedia? {
if (uri == null) return null
val resolvedMimeType = mimeType ?: context.contentResolver.getType(uri)
return LocalMedia(uri, resolvedMimeType)
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.local
import android.net.Uri
class FakeLocalMediaFactory() : LocalMediaFactory {
var mimeType: String? = null
override fun createFromUri(uri: Uri?, mimeType: String?): LocalMedia? {
if (uri == null) return null
return LocalMedia(uri, mimeType)
}
}

View file

@ -14,27 +14,14 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.messages.impl.media.viewer.model package io.element.android.features.messages.impl.media.local
import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
sealed interface MediaContentUiModel : Parcelable { @Parcelize
data class LocalMedia(
@Parcelize val uri: Uri,
data class Image( val mimeType: String?,
val body: String, ) : Parcelable
val url: String,
val blurhash: String?,
) : MediaContentUiModel {
val mediaRequestData = MediaRequestData(
url = url, kind = MediaRequestData.Kind.Content
)
}
@Parcelize
data class Video(
val body: String,
) : MediaContentUiModel
}

View file

@ -14,9 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.local
// TODO Add your events or remove the file completely if no events import android.net.Uri
sealed interface MediaViewerEvents {
object MyEvent : MediaViewerEvents interface LocalMediaFactory {
fun createFromUri(uri: Uri?, mimeType: String?): LocalMedia?
} }

View file

@ -0,0 +1,113 @@
/*
* 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.features.messages.impl.media.local
import android.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage
import io.element.android.libraries.designsystem.components.ZoomableBox
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun LocalMediaView(
localMedia: LocalMedia,
modifier: Modifier = Modifier
) {
when {
MimeTypes.isImage(localMedia.mimeType) -> MediaImageView(
uri = localMedia.uri,
modifier = modifier
)
MimeTypes.isVideo(localMedia.mimeType) -> MediaVideoView(
uri = localMedia.uri,
modifier = modifier
)
else -> Unit
}
}
@Composable
private fun MediaImageView(
uri: Uri,
modifier: Modifier = Modifier,
) {
ZoomableBox(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
AsyncImage(
modifier = Modifier
.zoomable()
.fillMaxSize(),
model = uri,
contentDescription = "Image",
contentScale = ContentScale.Fit,
)
}
}
@UnstableApi
@Composable
fun MediaVideoView(
uri: Uri,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val exoPlayer = ExoPlayer.Builder(LocalContext.current).build()
val mediaItem = MediaItem.Builder()
.setUri(uri)
.build()
exoPlayer.playWhenReady
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
AndroidView(
factory = {
PlayerView(context).apply {
player = exoPlayer
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
}
}, 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()
else -> Unit
}
}
}

View file

@ -24,25 +24,32 @@ import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MatrixMediaSource
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class MediaViewerNode @AssistedInject constructor( class MediaViewerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs(val mediaContent: MediaContentUiModel) : NodeInputs data class Inputs(
val name: String,
val mediaSource: MatrixMediaSource,
) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.name, inputs.mediaSource)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present()
MediaViewerView( MediaViewerView(
state = MediaViewerState(inputs.mediaContent), state = state,
modifier = modifier modifier = modifier
) )
} }

View file

@ -0,0 +1,63 @@
/*
* 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.features.messages.impl.media.viewer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.media.MatrixMediaSource
class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val name: String,
@Assisted private val mediaSource: MatrixMediaSource,
private val localMediaFactory: LocalMediaFactory,
private val client: MatrixClient,
) : Presenter<MediaViewerState> {
@AssistedFactory
interface Factory {
fun create(name: String, mediaSource: MatrixMediaSource): MediaViewerPresenter
}
@Composable
override fun present(): MediaViewerState {
val localMedia by produceState<Async<LocalMedia>>(initialValue = Async.Uninitialized) {
value = Async.Loading(null)
//TODO we are missing some permissions to use this API
client.mediaLoader.loadMediaFile(mediaSource, null)
.onSuccess {
val localMedia = localMediaFactory.createFromUri(uri = it, null)
Async.Success(localMedia)
}.onFailure {
Async.Failure(it, null)
}
}
return MediaViewerState(
name = name,
downloadedMedia = localMedia,
)
}
}

View file

@ -16,8 +16,10 @@
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.viewer
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.architecture.Async
data class MediaViewerState( data class MediaViewerState(
val mediaContent: MediaContentUiModel val name: String,
val downloadedMedia: Async<LocalMedia>
) )

View file

@ -17,22 +17,17 @@
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.viewer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel import io.element.android.libraries.architecture.Async
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> { open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState> override val values: Sequence<MediaViewerState>
get() = sequenceOf( get() = sequenceOf(
aMediaViewerState(), aMediaViewerState(),
// Add other states here
) )
} }
fun aMediaViewerState() = MediaViewerState( fun aMediaViewerState() = MediaViewerState(
mediaContent = aMediaImage(), name = "A media",
downloadedMedia = Async.Uninitialized
) )
private fun aMediaImage() = MediaContentUiModel.Image(
body = "a body",
url = "",
blurhash = null,
)

View file

@ -14,84 +14,50 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.viewer
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.ZoomableBox import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
@Composable @Composable
fun MediaViewerView( fun MediaViewerView(
state: MediaViewerState, state: MediaViewerState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box( Scaffold(modifier) {
modifier = modifier Box(
.fillMaxSize() modifier = Modifier.fillMaxSize(),
.background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.Center
contentAlignment = Alignment.Center, ) {
) { when (state.downloadedMedia) {
when (state.mediaContent) { is Async.Success -> LocalMediaView(state.downloadedMedia.state)
is MediaContentUiModel.Image -> MediaImageViewer(state.mediaContent) is Async.Failure -> ErrorDialog(
is MediaContentUiModel.Video -> MediaVideoViewer(state.mediaContent) content = "Error while downloading the media",
)
else -> CircularProgressIndicator(
strokeWidth = 2.dp,
)
}
} }
} }
} }
@Composable
private fun MediaImageViewer(
image: MediaContentUiModel.Image,
modifier: Modifier = Modifier,
) {
ZoomableBox(
modifier = modifier.fillMaxSize(),
) {
BlurHashAsyncImage(
blurHash = image.blurhash,
modifier = Modifier.fillMaxSize().zoomable(),
model = image.mediaRequestData,
contentScale = ContentScale.Fit,
)
}
}
@Composable
private fun MediaVideoViewer(
video: MediaContentUiModel.Video,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
}
}
@Preview @Preview
@Composable @Composable
fun MediaViewerViewLightPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = fun MediaViewerViewLightPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) =

View file

@ -16,8 +16,10 @@
package io.element.android.features.messages.impl.textcomposer package io.element.android.features.messages.impl.textcomposer
import androidx.compose.runtime.Immutable
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
@Immutable
sealed interface MessageComposerEvents { sealed interface MessageComposerEvents {
object ToggleFullScreenState : MessageComposerEvents object ToggleFullScreenState : MessageComposerEvents
data class SendMessage(val message: String) : MessageComposerEvents data class SendMessage(val message: String) : MessageComposerEvents

View file

@ -16,6 +16,8 @@
package io.element.android.features.messages.impl.textcomposer package io.element.android.features.messages.impl.textcomposer
import android.annotation.SuppressLint
import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@ -25,6 +27,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.media3.common.MimeTypes
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.data.toStableCharSequence import io.element.android.libraries.core.data.toStableCharSequence
@ -35,9 +40,9 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediapickers.PickerProvider import io.element.android.libraries.mediapickers.PickerProvider
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@SingleIn(RoomScope::class) @SingleIn(RoomScope::class)
@ -46,27 +51,32 @@ class MessageComposerPresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val localMediaFactory: LocalMediaFactory,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@SuppressLint("UnsafeOptInUsageError")
@Composable @Composable
override fun present(): MessageComposerState { override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri -> val attachmentsState = remember {
Timber.d("Media picked from $uri") mutableStateOf<AttachmentsState>(AttachmentsState.None)
}) }
val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { uri -> fun handlePickedMedia(uri: Uri?, mimeType: String? = null) {
Timber.d("File picked from $uri") val localMedia = localMediaFactory.createFromUri(uri, mimeType)
}) attachmentsState.value = if (localMedia == null) {
AttachmentsState.None
} else {
val mediaAttachment = Attachment.Media(localMedia)
AttachmentsState.Previewing(persistentListOf(mediaAttachment))
}
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { uri -> val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { handlePickedMedia(it) })
Timber.d("Photo saved at $uri") val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { handlePickedMedia(it) })
}) val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { handlePickedMedia(it, MimeTypes.IMAGE_JPEG) }, deleteAfter = false)
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { handlePickedMedia(it, MimeTypes.VIDEO_MP4) }, deleteAfter = false)
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { uri ->
Timber.d("Video saved at $uri")
})
val isFullScreen = rememberSaveable { val isFullScreen = rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
@ -129,6 +139,7 @@ class MessageComposerPresenter @Inject constructor(
isFullScreen = isFullScreen.value, isFullScreen = isFullScreen.value,
mode = composerMode.value, mode = composerMode.value,
attachmentSourcePicker = attachmentSourcePicker, attachmentSourcePicker = attachmentSourcePicker,
attachmentsState = attachmentsState.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -17,8 +17,10 @@
package io.element.android.features.messages.impl.textcomposer package io.element.android.features.messages.impl.textcomposer
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class MessageComposerState( data class MessageComposerState(
@ -26,11 +28,18 @@ data class MessageComposerState(
val isFullScreen: Boolean, val isFullScreen: Boolean,
val mode: MessageComposerMode, val mode: MessageComposerMode,
val attachmentSourcePicker: AttachmentSourcePicker?, val attachmentSourcePicker: AttachmentSourcePicker?,
val attachmentsState: AttachmentsState,
val eventSink: (MessageComposerEvents) -> Unit val eventSink: (MessageComposerEvents) -> Unit
) { ) {
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not() val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
} }
@Immutable
sealed interface AttachmentsState {
object None : AttachmentsState
data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
}
sealed interface AttachmentSourcePicker { sealed interface AttachmentSourcePicker {
object AllMedia : AttachmentSourcePicker object AllMedia : AttachmentSourcePicker
object Camera : AttachmentSourcePicker object Camera : AttachmentSourcePicker

View file

@ -32,5 +32,6 @@ fun aMessageComposerState() = MessageComposerState(
isFullScreen = false, isFullScreen = false,
mode = MessageComposerMode.Normal(content = ""), mode = MessageComposerMode.Normal(content = ""),
attachmentSourcePicker = null, attachmentSourcePicker = null,
attachmentsState = AttachmentsState.None,
eventSink = {} eventSink = {}
) )

View file

@ -17,6 +17,7 @@ recyclerview = "1.3.0"
lifecycle = "2.6.1" lifecycle = "2.6.1"
activity = "1.7.1" activity = "1.7.1"
startup = "1.1.1" startup = "1.1.1"
media3 = "1.0.1"
# Compose # Compose
compose_bom = "2023.04.01" compose_bom = "2023.04.01"
@ -69,6 +70,8 @@ androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt
androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx_splash = "androidx.core:core-splashscreen:1.0.1" androidx_splash = "androidx.core:core-splashscreen:1.0.1"
androidx_security_crypto = "androidx.security:security-crypto:1.0.0" androidx_security_crypto = "androidx.security:security-crypto:1.0.0"
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" } androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }