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.androidx.recyclerview)
implementation(libs.jsoup)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.accompanist.systemui)
testImplementation(libs.test.junit)
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.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
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.model.MediaContentUiModel
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.TimelineItemVideoContent
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MatrixMediaSource
import kotlinx.android.parcel.Parcelize
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class)
class MessagesFlowNode @AssistedInject constructor(
@@ -57,7 +62,10 @@ class MessagesFlowNode @AssistedInject constructor(
object Messages : NavTarget
@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()
@@ -73,25 +81,35 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onEventClicked(event: TimelineItem.Event) {
processEventClicked(event)
}
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
backstack.push(NavTarget.AttachmentPreview(attachments.first()))
}
}
createNode<MessagesNode>(buildContext, listOf(callback))
}
is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs(navTarget.mediaContent)
val inputs = MediaViewerNode.Inputs(navTarget.title, navTarget.mediaSource)
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) {
when (event.content) {
is TimelineItemImageContent -> {
val mediaContent = MediaContentUiModel.Image(
body = event.content.body,
url = event.content.mediaRequestData.url,
blurhash = event.content.blurhash
)
backstack.push(NavTarget.MediaViewer(mediaContent))
val mediaSource = event.content.mediaSource
val navTarget = NavTarget.MediaViewer(event.content.body, mediaSource)
backstack.push(navTarget)
}
is TimelineItemVideoContent -> {
val mediaSource = event.content.videoSource
val navTarget = NavTarget.MediaViewer(event.content.body, mediaSource)
backstack.push(navTarget)
}
else -> Unit
}
@@ -102,6 +120,7 @@ class MessagesFlowNode @AssistedInject constructor(
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberBackstackFader()
)
}
}

View File

@@ -25,8 +25,10 @@ import com.bumble.appyx.core.plugin.plugins
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.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.di.RoomScope
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class)
class MessagesNode @AssistedInject constructor(
@@ -40,6 +42,7 @@ class MessagesNode @AssistedInject constructor(
interface Callback : Plugin {
fun onRoomDetailsClicked()
fun onEventClicked(event: TimelineItem.Event)
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
}
private fun onRoomDetailsClicked() {
@@ -50,6 +53,10 @@ class MessagesNode @AssistedInject constructor(
callback?.onEventClicked(event)
}
private fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
callback?.onPreviewAttachments(attachments)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -58,6 +65,7 @@ class MessagesNode @AssistedInject constructor(
onBackPressed = this::navigateUp,
onRoomDetailsClicked = this::onRoomDetailsClicked,
onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments,
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.ActionListView
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.AttachmentsState
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.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.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -89,6 +92,7 @@ fun MessagesView(
onBackPressed: () -> Unit,
onRoomDetailsClicked: () -> Unit,
onEventClicked: (event: TimelineItem.Event) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
@@ -105,6 +109,13 @@ fun MessagesView(
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
val coroutineScope = rememberCoroutineScope()
val attachmentsState = state.composerState.attachmentsState
if (attachmentsState is AttachmentsState.Previewing) {
LaunchedEffect(attachmentsState) {
onPreviewAttachments(attachmentsState.attachments)
}
}
BackHandler(enabled = bottomSheetState.isVisible) {
coroutineScope.launch {
bottomSheetState.hide()
@@ -327,5 +338,5 @@ internal fun MessagesViewDarkPreview(@PreviewParameter(MessagesStateProvider::cl
@Composable
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.
*/
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 io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlinx.android.parcel.Parcelize
sealed interface MediaContentUiModel : Parcelable {
@Parcelize
data class Image(
val body: String,
val url: String,
val blurhash: String?,
) : MediaContentUiModel {
val mediaRequestData = MediaRequestData(
url = url, kind = MediaRequestData.Kind.Content
)
}
@Parcelize
data class Video(
val body: String,
) : MediaContentUiModel
}
@Parcelize
data class LocalMedia(
val uri: Uri,
val mimeType: String?,
) : Parcelable

View File

@@ -14,9 +14,10 @@
* 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
sealed interface MediaViewerEvents {
object MyEvent : MediaViewerEvents
import android.net.Uri
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.AssistedInject
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.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MatrixMediaSource
@ContributesNode(RoomScope::class)
class MediaViewerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
) : 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 presenter = presenterFactory.create(inputs.name, inputs.mediaSource)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
MediaViewerView(
state = MediaViewerState(inputs.mediaContent),
state = state,
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
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(
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
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> {
override val values: Sequence<MediaViewerState>
get() = sequenceOf(
aMediaViewerState(),
// Add other states here
)
}
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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
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.BoxScope
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.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.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.PreviewParameter
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
import io.element.android.libraries.designsystem.components.ZoomableBox
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.media.local.LocalMediaView
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.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
@Composable
fun MediaViewerView(
state: MediaViewerState,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
when (state.mediaContent) {
is MediaContentUiModel.Image -> MediaImageViewer(state.mediaContent)
is MediaContentUiModel.Video -> MediaVideoViewer(state.mediaContent)
Scaffold(modifier) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
when (state.downloadedMedia) {
is Async.Success -> LocalMediaView(state.downloadedMedia.state)
is Async.Failure -> ErrorDialog(
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
@Composable
fun MediaViewerViewLightPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) =

View File

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

View File

@@ -16,6 +16,8 @@
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.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -25,6 +27,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
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.core.data.StableCharSequence
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.mediapickers.PickerProvider
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@SingleIn(RoomScope::class)
@@ -46,27 +51,32 @@ class MessageComposerPresenter @Inject constructor(
private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService,
private val localMediaFactory: LocalMediaFactory,
) : Presenter<MessageComposerState> {
@SuppressLint("UnsafeOptInUsageError")
@Composable
override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope()
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri ->
Timber.d("Media picked from $uri")
})
val attachmentsState = remember {
mutableStateOf<AttachmentsState>(AttachmentsState.None)
}
val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { uri ->
Timber.d("File picked from $uri")
})
fun handlePickedMedia(uri: Uri?, mimeType: String? = null) {
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 ->
Timber.d("Photo saved at $uri")
})
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { uri ->
Timber.d("Video saved at $uri")
})
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { handlePickedMedia(it) })
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 isFullScreen = rememberSaveable {
mutableStateOf(false)
@@ -129,6 +139,7 @@ class MessageComposerPresenter @Inject constructor(
isFullScreen = isFullScreen.value,
mode = composerMode.value,
attachmentSourcePicker = attachmentSourcePicker,
attachmentsState = attachmentsState.value,
eventSink = ::handleEvents
)
}

View File

@@ -17,8 +17,10 @@
package io.element.android.features.messages.impl.textcomposer
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.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class MessageComposerState(
@@ -26,11 +28,18 @@ data class MessageComposerState(
val isFullScreen: Boolean,
val mode: MessageComposerMode,
val attachmentSourcePicker: AttachmentSourcePicker?,
val attachmentsState: AttachmentsState,
val eventSink: (MessageComposerEvents) -> Unit
) {
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 {
object AllMedia : AttachmentSourcePicker
object Camera : AttachmentSourcePicker

View File

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

View File

@@ -17,6 +17,7 @@ recyclerview = "1.3.0"
lifecycle = "2.6.1"
activity = "1.7.1"
startup = "1.1.1"
media3 = "1.0.1"
# Compose
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_splash = "androidx.core:core-splashscreen:1.0.1"
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_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }