diff --git a/CHANGES.md b/CHANGES.md index 589972edc1..60d66014ef 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,7 @@ Improvements 🙌: - Cleaning chunks with lots of events as long as a threshold has been exceeded (35_000 events in DB) (#1634) - Creating and listening to EventInsertEntity. (#1634) - Handling (almost) properly the groups fetching (#1634) + - Improve fullscreen media display (#327) Bugfix 🐛: - Regression | Share action menu do not work (#1647) diff --git a/attachment-viewer/.gitignore b/attachment-viewer/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/attachment-viewer/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/attachment-viewer/build.gradle b/attachment-viewer/build.gradle new file mode 100644 index 0000000000..3a5c3298d4 --- /dev/null +++ b/attachment-viewer/build.gradle @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2020 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. + */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' + +buildscript { + repositories { + maven { + url 'https://jitpack.io' + content { + // PhotoView + includeGroupByRegex 'com\\.github\\.chrisbanes' + } + } + jcenter() + } + +} + +android { + compileSdkVersion 29 + + defaultConfig { + minSdkVersion 21 + targetSdkVersion 29 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + implementation 'com.github.chrisbanes:PhotoView:2.0.0' + + implementation 'io.reactivex.rxjava2:rxkotlin:2.3.0' + implementation 'io.reactivex.rxjava2:rxandroid:2.1.1' + + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.3.0' + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'com.google.android.material:material:1.1.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'androidx.navigation:navigation-fragment-ktx:2.1.0' + implementation 'androidx.navigation:navigation-ui-ktx:2.1.0' + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + +} \ No newline at end of file diff --git a/attachment-viewer/consumer-rules.pro b/attachment-viewer/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/attachment-viewer/proguard-rules.pro b/attachment-viewer/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/attachment-viewer/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/attachment-viewer/src/main/AndroidManifest.xml b/attachment-viewer/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..ff8ec394d2 --- /dev/null +++ b/attachment-viewer/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AnimatedImageViewHolder.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AnimatedImageViewHolder.kt new file mode 100644 index 0000000000..f00a4eff30 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AnimatedImageViewHolder.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2020 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 im.vector.riotx.attachmentviewer + +import android.view.View +import android.widget.ImageView +import android.widget.ProgressBar + +class AnimatedImageViewHolder constructor(itemView: View) : + BaseViewHolder(itemView) { + + val touchImageView: ImageView = itemView.findViewById(R.id.imageView) + val imageLoaderProgress: ProgressBar = itemView.findViewById(R.id.imageLoaderProgress) + + internal val target = DefaultImageLoaderTarget(this, this.touchImageView) +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentEvents.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentEvents.kt new file mode 100644 index 0000000000..b2b6c9fe16 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentEvents.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020 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 im.vector.riotx.attachmentviewer + +sealed class AttachmentEvents { + data class VideoEvent(val isPlaying: Boolean, val progress: Int, val duration: Int) : AttachmentEvents() +} + +interface AttachmentEventListener { + fun onEvent(event: AttachmentEvents) +} + +sealed class AttachmentCommands { + object PauseVideo : AttachmentCommands() + object StartVideo : AttachmentCommands() + data class SeekTo(val percentProgress: Int) : AttachmentCommands() +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentSourceProvider.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentSourceProvider.kt new file mode 100644 index 0000000000..92a4f1d9e4 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentSourceProvider.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 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 im.vector.riotx.attachmentviewer + +import android.content.Context +import android.view.View + +sealed class AttachmentInfo(open val uid: String) { + data class Image(override val uid: String, val url: String, val data: Any?) : AttachmentInfo(uid) + data class AnimatedImage(override val uid: String, val url: String, val data: Any?) : AttachmentInfo(uid) + data class Video(override val uid: String, val url: String, val data: Any, val thumbnail: Image?) : AttachmentInfo(uid) +// data class Audio(override val uid: String, val url: String, val data: Any) : AttachmentInfo(uid) +// data class File(override val uid: String, val url: String, val data: Any) : AttachmentInfo(uid) +} + +interface AttachmentSourceProvider { + + fun getItemCount(): Int + + fun getAttachmentInfoAt(position: Int): AttachmentInfo + + fun loadImage(target: ImageLoaderTarget, info: AttachmentInfo.Image) + + fun loadImage(target: ImageLoaderTarget, info: AttachmentInfo.AnimatedImage) + + fun loadVideo(target: VideoLoaderTarget, info: AttachmentInfo.Video) + + fun overlayViewAtPosition(context: Context, position: Int): View? + + fun clear(id: String) +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentViewerActivity.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentViewerActivity.kt new file mode 100644 index 0000000000..8c2d4e9833 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentViewerActivity.kt @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * Copyright (C) 2018 stfalcon.com + * + * 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 im.vector.riotx.attachmentviewer + +import android.graphics.Color +import android.os.Bundle +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.ImageView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.GestureDetectorCompat +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.transition.TransitionManager +import androidx.viewpager2.widget.ViewPager2 +import kotlinx.android.synthetic.main.activity_attachment_viewer.* +import java.lang.ref.WeakReference +import kotlin.math.abs + +abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventListener { + + lateinit var pager2: ViewPager2 + lateinit var imageTransitionView: ImageView + lateinit var transitionImageContainer: ViewGroup + + var topInset = 0 + var bottomInset = 0 + var systemUiVisibility = true + + private var overlayView: View? = null + set(value) { + if (value == overlayView) return + overlayView?.let { rootContainer.removeView(it) } + rootContainer.addView(value) + value?.updatePadding(top = topInset, bottom = bottomInset) + field = value + } + + private lateinit var swipeDismissHandler: SwipeToDismissHandler + private lateinit var directionDetector: SwipeDirectionDetector + private lateinit var scaleDetector: ScaleGestureDetector + private lateinit var gestureDetector: GestureDetectorCompat + + var currentPosition = 0 + + private var swipeDirection: SwipeDirection? = null + + private fun isScaled() = attachmentsAdapter.isScaled(currentPosition) + + private var wasScaled: Boolean = false + private var isSwipeToDismissAllowed: Boolean = true + private lateinit var attachmentsAdapter: AttachmentsAdapter + private var isOverlayWasClicked = false + +// private val shouldDismissToBottom: Boolean +// get() = e == null +// || !externalTransitionImageView.isRectVisible +// || !isAtStartPosition + + private var isImagePagerIdle = true + + fun setSourceProvider(sourceProvider: AttachmentSourceProvider) { + attachmentsAdapter.attachmentSourceProvider = sourceProvider + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // This is important for the dispatchTouchEvent, if not we must correct + // the touch coordinates + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_IMMERSIVE) + window.setFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + window.setFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION, WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) + + setContentView(R.layout.activity_attachment_viewer) + attachmentPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL + attachmentsAdapter = AttachmentsAdapter() + attachmentPager.adapter = attachmentsAdapter + imageTransitionView = transitionImageView + transitionImageContainer = findViewById(R.id.transitionImageContainer) + pager2 = attachmentPager + directionDetector = createSwipeDirectionDetector() + gestureDetector = createGestureDetector() + + attachmentPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + isImagePagerIdle = state == ViewPager2.SCROLL_STATE_IDLE + } + + override fun onPageSelected(position: Int) { + onSelectedPositionChanged(position) + } + }) + + swipeDismissHandler = createSwipeToDismissHandler() + rootContainer.setOnTouchListener(swipeDismissHandler) + rootContainer.viewTreeObserver.addOnGlobalLayoutListener { swipeDismissHandler.translationLimit = dismissContainer.height / 4 } + + scaleDetector = createScaleGestureDetector() + + ViewCompat.setOnApplyWindowInsetsListener(rootContainer) { _, insets -> + overlayView?.updatePadding(top = insets.systemWindowInsetTop, bottom = insets.systemWindowInsetBottom) + topInset = insets.systemWindowInsetTop + bottomInset = insets.systemWindowInsetBottom + insets + } + } + + fun onSelectedPositionChanged(position: Int) { + attachmentsAdapter.recyclerView?.findViewHolderForAdapterPosition(currentPosition)?.let { + (it as? BaseViewHolder)?.onSelected(false) + } + attachmentsAdapter.recyclerView?.findViewHolderForAdapterPosition(position)?.let { + (it as? BaseViewHolder)?.onSelected(true) + if (it is VideoViewHolder) { + it.eventListener = WeakReference(this) + } + } + currentPosition = position + overlayView = attachmentsAdapter.attachmentSourceProvider?.overlayViewAtPosition(this@AttachmentViewerActivity, position) + } + + override fun onPause() { + attachmentsAdapter.onPause(currentPosition) + super.onPause() + } + + override fun onResume() { + super.onResume() + attachmentsAdapter.onResume(currentPosition) + } + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + // The zoomable view is configured to disallow interception when image is zoomed + + // Check if the overlay is visible, and wants to handle the click + if (overlayView?.isVisible == true && overlayView?.dispatchTouchEvent(ev) == true) { + return true + } + + // Log.v("ATTACHEMENTS", "================\ndispatchTouchEvent $ev") + handleUpDownEvent(ev) + + // Log.v("ATTACHEMENTS", "scaleDetector is in progress ${scaleDetector.isInProgress}") + // Log.v("ATTACHEMENTS", "pointerCount ${ev.pointerCount}") + // Log.v("ATTACHEMENTS", "wasScaled $wasScaled") + if (swipeDirection == null && (scaleDetector.isInProgress || ev.pointerCount > 1 || wasScaled)) { + wasScaled = true +// Log.v("ATTACHEMENTS", "dispatch to pager") + return attachmentPager.dispatchTouchEvent(ev) + } + + // Log.v("ATTACHEMENTS", "is current item scaled ${isScaled()}") + return (if (isScaled()) super.dispatchTouchEvent(ev) else handleTouchIfNotScaled(ev)).also { +// Log.v("ATTACHEMENTS", "\n================") + } + } + + private fun handleUpDownEvent(event: MotionEvent) { + // Log.v("ATTACHEMENTS", "handleUpDownEvent $event") + if (event.action == MotionEvent.ACTION_UP) { + handleEventActionUp(event) + } + + if (event.action == MotionEvent.ACTION_DOWN) { + handleEventActionDown(event) + } + + scaleDetector.onTouchEvent(event) + gestureDetector.onTouchEvent(event) + } + + private fun handleEventActionDown(event: MotionEvent) { + swipeDirection = null + wasScaled = false + attachmentPager.dispatchTouchEvent(event) + + swipeDismissHandler.onTouch(rootContainer, event) + isOverlayWasClicked = dispatchOverlayTouch(event) + } + + private fun handleEventActionUp(event: MotionEvent) { +// wasDoubleTapped = false + swipeDismissHandler.onTouch(rootContainer, event) + attachmentPager.dispatchTouchEvent(event) + isOverlayWasClicked = dispatchOverlayTouch(event) + } + + private fun handleSingleTap(event: MotionEvent, isOverlayWasClicked: Boolean) { + // TODO if there is no overlay, we should at least toggle system bars? + if (overlayView != null && !isOverlayWasClicked) { + toggleOverlayViewVisibility() + super.dispatchTouchEvent(event) + } + } + + private fun toggleOverlayViewVisibility() { + if (systemUiVisibility) { + // we hide + TransitionManager.beginDelayedTransition(rootContainer) + hideSystemUI() + overlayView?.isVisible = false + } else { + // we show + TransitionManager.beginDelayedTransition(rootContainer) + showSystemUI() + overlayView?.isVisible = true + } + } + + private fun handleTouchIfNotScaled(event: MotionEvent): Boolean { +// Log.v("ATTACHEMENTS", "handleTouchIfNotScaled $event") + directionDetector.handleTouchEvent(event) + + return when (swipeDirection) { + SwipeDirection.Up, SwipeDirection.Down -> { + if (isSwipeToDismissAllowed && !wasScaled && isImagePagerIdle) { + swipeDismissHandler.onTouch(rootContainer, event) + } else true + } + SwipeDirection.Left, SwipeDirection.Right -> { + attachmentPager.dispatchTouchEvent(event) + } + else -> true + } + } + + private fun handleSwipeViewMove(translationY: Float, translationLimit: Int) { + val alpha = calculateTranslationAlpha(translationY, translationLimit) + backgroundView.alpha = alpha + dismissContainer.alpha = alpha + overlayView?.alpha = alpha + } + + private fun dispatchOverlayTouch(event: MotionEvent): Boolean = + overlayView + ?.let { it.isVisible && it.dispatchTouchEvent(event) } + ?: false + + private fun calculateTranslationAlpha(translationY: Float, translationLimit: Int): Float = + 1.0f - 1.0f / translationLimit.toFloat() / 4f * abs(translationY) + + private fun createSwipeToDismissHandler() + : SwipeToDismissHandler = SwipeToDismissHandler( + swipeView = dismissContainer, + shouldAnimateDismiss = { shouldAnimateDismiss() }, + onDismiss = { animateClose() }, + onSwipeViewMove = ::handleSwipeViewMove) + + private fun createSwipeDirectionDetector() = + SwipeDirectionDetector(this) { swipeDirection = it } + + private fun createScaleGestureDetector() = + ScaleGestureDetector(this, ScaleGestureDetector.SimpleOnScaleGestureListener()) + + private fun createGestureDetector() = + GestureDetectorCompat(this, object : GestureDetector.SimpleOnGestureListener() { + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + if (isImagePagerIdle) { + handleSingleTap(e, isOverlayWasClicked) + } + return false + } + + override fun onDoubleTap(e: MotionEvent?): Boolean { + return super.onDoubleTap(e) + } + }) + + override fun onEvent(event: AttachmentEvents) { + if (overlayView is AttachmentEventListener) { + (overlayView as? AttachmentEventListener)?.onEvent(event) + } + } + + protected open fun shouldAnimateDismiss(): Boolean = true + + protected open fun animateClose() { + window.statusBarColor = Color.TRANSPARENT + finish() + } + + fun handle(commands: AttachmentCommands) { + (attachmentsAdapter.recyclerView?.findViewHolderForAdapterPosition(currentPosition) as? BaseViewHolder) + ?.handleCommand(commands) + } + + private fun hideSystemUI() { + systemUiVisibility = false + // Enables regular immersive mode. + // For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE. + // Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE + // Set the content to appear under the system bars so that the + // content doesn't resize when the system bars hide and show. + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + // Hide the nav bar and status bar + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN) + } + + // Shows the system bars by removing all the flags +// except for the ones that make the content appear under the system bars. + private fun showSystemUI() { + systemUiVisibility = true + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentsAdapter.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentsAdapter.kt new file mode 100644 index 0000000000..27bdfdc91d --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentsAdapter.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2020 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 im.vector.riotx.attachmentviewer + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView + +class AttachmentsAdapter : RecyclerView.Adapter() { + + var attachmentSourceProvider: AttachmentSourceProvider? = null + set(value) { + field = value + notifyDataSetChanged() + } + + var recyclerView: RecyclerView? = null + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = null + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder { + val inflater = LayoutInflater.from(parent.context) + val itemView = inflater.inflate(viewType, parent, false) + return when (viewType) { + R.layout.item_image_attachment -> ZoomableImageViewHolder(itemView) + R.layout.item_animated_image_attachment -> AnimatedImageViewHolder(itemView) + R.layout.item_video_attachment -> VideoViewHolder(itemView) + else -> UnsupportedViewHolder(itemView) + } + } + + override fun getItemViewType(position: Int): Int { + val info = attachmentSourceProvider!!.getAttachmentInfoAt(position) + return when (info) { + is AttachmentInfo.Image -> R.layout.item_image_attachment + is AttachmentInfo.Video -> R.layout.item_video_attachment + is AttachmentInfo.AnimatedImage -> R.layout.item_animated_image_attachment +// is AttachmentInfo.Audio -> TODO() +// is AttachmentInfo.File -> TODO() + } + } + + override fun getItemCount(): Int { + return attachmentSourceProvider?.getItemCount() ?: 0 + } + + override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { + attachmentSourceProvider?.getAttachmentInfoAt(position)?.let { + holder.bind(it) + when (it) { + is AttachmentInfo.Image -> { + attachmentSourceProvider?.loadImage((holder as ZoomableImageViewHolder).target, it) + } + is AttachmentInfo.AnimatedImage -> { + attachmentSourceProvider?.loadImage((holder as AnimatedImageViewHolder).target, it) + } + is AttachmentInfo.Video -> { + attachmentSourceProvider?.loadVideo((holder as VideoViewHolder).target, it) + } +// else -> { +// // } + } + } + } + + override fun onViewAttachedToWindow(holder: BaseViewHolder) { + holder.onAttached() + } + + override fun onViewRecycled(holder: BaseViewHolder) { + holder.onRecycled() + } + + override fun onViewDetachedFromWindow(holder: BaseViewHolder) { + holder.onDetached() + } + + fun isScaled(position: Int): Boolean { + val holder = recyclerView?.findViewHolderForAdapterPosition(position) + if (holder is ZoomableImageViewHolder) { + return holder.touchImageView.attacher.scale > 1f + } + return false + } + + fun onPause(position: Int) { + val holder = recyclerView?.findViewHolderForAdapterPosition(position) as? BaseViewHolder + holder?.entersBackground() + } + + fun onResume(position: Int) { + val holder = recyclerView?.findViewHolderForAdapterPosition(position) as? BaseViewHolder + holder?.entersForeground() + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/BaseViewHolder.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/BaseViewHolder.kt new file mode 100644 index 0000000000..49b47c11ff --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/BaseViewHolder.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 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 im.vector.riotx.attachmentviewer + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +abstract class BaseViewHolder constructor(itemView: View) : + RecyclerView.ViewHolder(itemView) { + + open fun onRecycled() { + boundResourceUid = null + } + + open fun onAttached() {} + open fun onDetached() {} + open fun entersBackground() {} + open fun entersForeground() {} + open fun onSelected(selected: Boolean) {} + + open fun handleCommand(commands: AttachmentCommands) {} + + var boundResourceUid: String? = null + + open fun bind(attachmentInfo: AttachmentInfo) { + boundResourceUid = attachmentInfo.uid + } +} + +class UnsupportedViewHolder constructor(itemView: View) : + BaseViewHolder(itemView) diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ImageLoaderTarget.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ImageLoaderTarget.kt new file mode 100644 index 0000000000..bb59c9e01e --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ImageLoaderTarget.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2020 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 im.vector.riotx.attachmentviewer + +import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams + +interface ImageLoaderTarget { + + fun contextView(): ImageView + + fun onResourceLoading(uid: String, placeholder: Drawable?) + + fun onLoadFailed(uid: String, errorDrawable: Drawable?) + + fun onResourceCleared(uid: String, placeholder: Drawable?) + + fun onResourceReady(uid: String, resource: Drawable) +} + +internal class DefaultImageLoaderTarget(val holder: AnimatedImageViewHolder, private val contextView: ImageView) + : ImageLoaderTarget { + override fun contextView(): ImageView { + return contextView + } + + override fun onResourceLoading(uid: String, placeholder: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = true + } + + override fun onLoadFailed(uid: String, errorDrawable: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = false + } + + override fun onResourceCleared(uid: String, placeholder: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.touchImageView.setImageDrawable(placeholder) + } + + override fun onResourceReady(uid: String, resource: Drawable) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = false + // Glide mess up the view size :/ + holder.touchImageView.updateLayoutParams { + width = LinearLayout.LayoutParams.MATCH_PARENT + height = LinearLayout.LayoutParams.MATCH_PARENT + } + holder.touchImageView.setImageDrawable(resource) + if (resource is Animatable) { + resource.start() + } + } + + internal class ZoomableImageTarget(val holder: ZoomableImageViewHolder, private val contextView: ImageView) : ImageLoaderTarget { + override fun contextView() = contextView + + override fun onResourceLoading(uid: String, placeholder: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = true + } + + override fun onLoadFailed(uid: String, errorDrawable: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = false + } + + override fun onResourceCleared(uid: String, placeholder: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.touchImageView.setImageDrawable(placeholder) + } + + override fun onResourceReady(uid: String, resource: Drawable) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = false + // Glide mess up the view size :/ + holder.touchImageView.updateLayoutParams { + width = LinearLayout.LayoutParams.MATCH_PARENT + height = LinearLayout.LayoutParams.MATCH_PARENT + } + holder.touchImageView.setImageDrawable(resource) + } + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirection.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirection.kt new file mode 100644 index 0000000000..ebe8784e15 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirection.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * Copyright (C) 2018 stfalcon.com + * + * 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 im.vector.riotx.attachmentviewer + +sealed class SwipeDirection { + object NotDetected : SwipeDirection() + object Up : SwipeDirection() + object Down : SwipeDirection() + object Left : SwipeDirection() + object Right : SwipeDirection() + + companion object { + fun fromAngle(angle: Double): SwipeDirection { + return when (angle) { + in 0.0..45.0 -> Right + in 45.0..135.0 -> Up + in 135.0..225.0 -> Left + in 225.0..315.0 -> Down + in 315.0..360.0 -> Right + else -> NotDetected + } + } + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirectionDetector.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirectionDetector.kt new file mode 100644 index 0000000000..0cf9a19ab1 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirectionDetector.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * Copyright (C) 2018 stfalcon.com + * + * 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 im.vector.riotx.attachmentviewer + +import android.content.Context +import android.view.MotionEvent +import kotlin.math.sqrt + +class SwipeDirectionDetector( + context: Context, + private val onDirectionDetected: (SwipeDirection) -> Unit +) { + + private val touchSlop: Int = android.view.ViewConfiguration.get(context).scaledTouchSlop + private var startX: Float = 0f + private var startY: Float = 0f + private var isDetected: Boolean = false + + fun handleTouchEvent(event: MotionEvent) { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + startX = event.x + startY = event.y + } + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + if (!isDetected) { + onDirectionDetected(SwipeDirection.NotDetected) + } + startY = 0.0f + startX = startY + isDetected = false + } + MotionEvent.ACTION_MOVE -> if (!isDetected && getEventDistance(event) > touchSlop) { + isDetected = true + onDirectionDetected(getDirection(startX, startY, event.x, event.y)) + } + } + } + + /** + * Given two points in the plane p1=(x1, x2) and p2=(y1, y1), this method + * returns the direction that an arrow pointing from p1 to p2 would have. + * + * @param x1 the x position of the first point + * @param y1 the y position of the first point + * @param x2 the x position of the second point + * @param y2 the y position of the second point + * @return the direction + */ + private fun getDirection(x1: Float, y1: Float, x2: Float, y2: Float): SwipeDirection { + val angle = getAngle(x1, y1, x2, y2) + return SwipeDirection.fromAngle(angle) + } + + /** + * Finds the angle between two points in the plane (x1,y1) and (x2, y2) + * The angle is measured with 0/360 being the X-axis to the right, angles + * increase counter clockwise. + * + * @param x1 the x position of the first point + * @param y1 the y position of the first point + * @param x2 the x position of the second point + * @param y2 the y position of the second point + * @return the angle between two points + */ + private fun getAngle(x1: Float, y1: Float, x2: Float, y2: Float): Double { + val rad = Math.atan2((y1 - y2).toDouble(), (x2 - x1).toDouble()) + Math.PI + return (rad * 180 / Math.PI + 180) % 360 + } + + private fun getEventDistance(ev: MotionEvent): Float { + val dx = ev.getX(0) - startX + val dy = ev.getY(0) - startY + return sqrt((dx * dx + dy * dy).toDouble()).toFloat() + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeToDismissHandler.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeToDismissHandler.kt new file mode 100644 index 0000000000..ca93d4f73a --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeToDismissHandler.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * Copyright (C) 2018 stfalcon.com + * + * 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 im.vector.riotx.attachmentviewer + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.annotation.SuppressLint +import android.graphics.Rect +import android.view.MotionEvent +import android.view.View +import android.view.ViewPropertyAnimator +import android.view.animation.AccelerateInterpolator + +class SwipeToDismissHandler( + private val swipeView: View, + private val onDismiss: () -> Unit, + private val onSwipeViewMove: (translationY: Float, translationLimit: Int) -> Unit, + private val shouldAnimateDismiss: () -> Boolean +) : View.OnTouchListener { + + companion object { + private const val ANIMATION_DURATION = 200L + } + + var translationLimit: Int = swipeView.height / 4 + private var isTracking = false + private var startY: Float = 0f + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + if (swipeView.hitRect.contains(event.x.toInt(), event.y.toInt())) { + isTracking = true + } + startY = event.y + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (isTracking) { + isTracking = false + onTrackingEnd(v.height) + } + return true + } + MotionEvent.ACTION_MOVE -> { + if (isTracking) { + val translationY = event.y - startY + swipeView.translationY = translationY + onSwipeViewMove(translationY, translationLimit) + } + return true + } + else -> { + return false + } + } + } + + internal fun initiateDismissToBottom() { + animateTranslation(swipeView.height.toFloat()) + } + + private fun onTrackingEnd(parentHeight: Int) { + val animateTo = when { + swipeView.translationY < -translationLimit -> -parentHeight.toFloat() + swipeView.translationY > translationLimit -> parentHeight.toFloat() + else -> 0f + } + + if (animateTo != 0f && !shouldAnimateDismiss()) { + onDismiss() + } else { + animateTranslation(animateTo) + } + } + + private fun animateTranslation(translationTo: Float) { + swipeView.animate() + .translationY(translationTo) + .setDuration(ANIMATION_DURATION) + .setInterpolator(AccelerateInterpolator()) + .setUpdateListener { onSwipeViewMove(swipeView.translationY, translationLimit) } + .setAnimatorListener(onAnimationEnd = { + if (translationTo != 0f) { + onDismiss() + } + + // remove the update listener, otherwise it will be saved on the next animation execution: + swipeView.animate().setUpdateListener(null) + }) + .start() + } +} + +internal fun ViewPropertyAnimator.setAnimatorListener( + onAnimationEnd: ((Animator?) -> Unit)? = null, + onAnimationStart: ((Animator?) -> Unit)? = null +) = this.setListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator?) { + onAnimationEnd?.invoke(animation) + } + + override fun onAnimationStart(animation: Animator?) { + onAnimationStart?.invoke(animation) + } + }) + +internal val View?.hitRect: Rect + get() = Rect().also { this?.getHitRect(it) } diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoLoaderTarget.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoLoaderTarget.kt new file mode 100644 index 0000000000..548c6431e5 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoLoaderTarget.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2020 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 im.vector.riotx.attachmentviewer + +import android.graphics.drawable.Drawable +import android.widget.ImageView +import androidx.core.view.isVisible +import java.io.File + +interface VideoLoaderTarget { + fun contextView(): ImageView + + fun onThumbnailResourceLoading(uid: String, placeholder: Drawable?) + + fun onThumbnailLoadFailed(uid: String, errorDrawable: Drawable?) + + fun onThumbnailResourceCleared(uid: String, placeholder: Drawable?) + + fun onThumbnailResourceReady(uid: String, resource: Drawable) + + fun onVideoFileLoading(uid: String) + fun onVideoFileLoadFailed(uid: String) + fun onVideoFileReady(uid: String, file: File) +} + +internal class DefaultVideoLoaderTarget(val holder: VideoViewHolder, private val contextView: ImageView) : VideoLoaderTarget { + override fun contextView(): ImageView = contextView + + override fun onThumbnailResourceLoading(uid: String, placeholder: Drawable?) { + } + + override fun onThumbnailLoadFailed(uid: String, errorDrawable: Drawable?) { + } + + override fun onThumbnailResourceCleared(uid: String, placeholder: Drawable?) { + } + + override fun onThumbnailResourceReady(uid: String, resource: Drawable) { + if (holder.boundResourceUid != uid) return + holder.thumbnailImage.setImageDrawable(resource) + } + + override fun onVideoFileLoading(uid: String) { + if (holder.boundResourceUid != uid) return + holder.thumbnailImage.isVisible = true + holder.loaderProgressBar.isVisible = true + holder.videoView.isVisible = false + } + + override fun onVideoFileLoadFailed(uid: String) { + if (holder.boundResourceUid != uid) return + holder.videoFileLoadError() + } + + override fun onVideoFileReady(uid: String, file: File) { + if (holder.boundResourceUid != uid) return + holder.thumbnailImage.isVisible = false + holder.loaderProgressBar.isVisible = false + holder.videoView.isVisible = true + holder.videoReady(file) + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoViewHolder.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoViewHolder.kt new file mode 100644 index 0000000000..e1a5a9864f --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoViewHolder.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2020 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 im.vector.riotx.attachmentviewer + +import android.view.View +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.VideoView +import androidx.core.view.isVisible +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import java.io.File +import java.lang.ref.WeakReference +import java.util.concurrent.TimeUnit + +// TODO, it would be probably better to use a unique media player +// for better customization and control +// But for now VideoView is enough, it released player when detached, we use a timer to update progress +class VideoViewHolder constructor(itemView: View) : + BaseViewHolder(itemView) { + + private var isSelected = false + private var mVideoPath: String? = null + private var progressDisposable: Disposable? = null + private var progress: Int = 0 + private var wasPaused = false + + var eventListener: WeakReference? = null + + val thumbnailImage: ImageView = itemView.findViewById(R.id.videoThumbnailImage) + val videoView: VideoView = itemView.findViewById(R.id.videoView) + val loaderProgressBar: ProgressBar = itemView.findViewById(R.id.videoLoaderProgress) + val videoControlIcon: ImageView = itemView.findViewById(R.id.videoControlIcon) + val errorTextView: TextView = itemView.findViewById(R.id.videoMediaViewerErrorView) + + internal val target = DefaultVideoLoaderTarget(this, thumbnailImage) + + override fun onRecycled() { + super.onRecycled() + progressDisposable?.dispose() + progressDisposable = null + mVideoPath = null + } + + fun videoReady(file: File) { + mVideoPath = file.path + if (isSelected) { + startPlaying() + } + } + + fun videoFileLoadError() { + } + + override fun entersBackground() { + if (videoView.isPlaying) { + progress = videoView.currentPosition + progressDisposable?.dispose() + progressDisposable = null + videoView.stopPlayback() + videoView.pause() + } + } + + override fun entersForeground() { + onSelected(isSelected) + } + + override fun onSelected(selected: Boolean) { + if (!selected) { + if (videoView.isPlaying) { + progress = videoView.currentPosition + videoView.stopPlayback() + } else { + progress = 0 + } + progressDisposable?.dispose() + progressDisposable = null + } else { + if (mVideoPath != null) { + startPlaying() + } + } + isSelected = true + } + + private fun startPlaying() { + thumbnailImage.isVisible = false + loaderProgressBar.isVisible = false + videoView.isVisible = true + + videoView.setOnPreparedListener { + progressDisposable?.dispose() + progressDisposable = Observable.interval(100, TimeUnit.MILLISECONDS) + .timeInterval() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + val duration = videoView.duration + val progress = videoView.currentPosition + val isPlaying = videoView.isPlaying +// Log.v("FOO", "isPlaying $isPlaying $progress/$duration") + eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration)) + } + } + + videoView.setVideoPath(mVideoPath) + if (!wasPaused) { + videoView.start() + if (progress > 0) { + videoView.seekTo(progress) + } + } + } + + override fun handleCommand(commands: AttachmentCommands) { + if (!isSelected) return + when (commands) { + AttachmentCommands.StartVideo -> { + wasPaused = false + videoView.start() + } + AttachmentCommands.PauseVideo -> { + wasPaused = true + videoView.pause() + } + is AttachmentCommands.SeekTo -> { + val duration = videoView.duration + if (duration > 0) { + val seekDuration = duration * (commands.percentProgress / 100f) + videoView.seekTo(seekDuration.toInt()) + } + } + } + } + + override fun bind(attachmentInfo: AttachmentInfo) { + super.bind(attachmentInfo) + progress = 0 + wasPaused = false + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ZoomableImageViewHolder.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ZoomableImageViewHolder.kt new file mode 100644 index 0000000000..3eb06e4c27 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ZoomableImageViewHolder.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020 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 im.vector.riotx.attachmentviewer + +import android.view.View +import android.widget.ProgressBar +import com.github.chrisbanes.photoview.PhotoView + +class ZoomableImageViewHolder constructor(itemView: View) : + BaseViewHolder(itemView) { + + val touchImageView: PhotoView = itemView.findViewById(R.id.touchImageView) + val imageLoaderProgress: ProgressBar = itemView.findViewById(R.id.imageLoaderProgress) + + init { + touchImageView.setAllowParentInterceptOnEdge(false) + touchImageView.setOnScaleChangeListener { scaleFactor, _, _ -> + // Log.v("ATTACHEMENTS", "scaleFactor $scaleFactor") + // It's a bit annoying but when you pitch down the scaling + // is not exactly one :/ + touchImageView.setAllowParentInterceptOnEdge(scaleFactor <= 1.0008f) + } + touchImageView.setScale(1.0f, true) + touchImageView.setAllowParentInterceptOnEdge(true) + } + + internal val target = DefaultImageLoaderTarget.ZoomableImageTarget(this, touchImageView) +} diff --git a/attachment-viewer/src/main/res/layout/activity_attachment_viewer.xml b/attachment-viewer/src/main/res/layout/activity_attachment_viewer.xml new file mode 100644 index 0000000000..a8a68db1a5 --- /dev/null +++ b/attachment-viewer/src/main/res/layout/activity_attachment_viewer.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/attachment-viewer/src/main/res/layout/item_animated_image_attachment.xml b/attachment-viewer/src/main/res/layout/item_animated_image_attachment.xml new file mode 100644 index 0000000000..1096267124 --- /dev/null +++ b/attachment-viewer/src/main/res/layout/item_animated_image_attachment.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/attachment-viewer/src/main/res/layout/item_image_attachment.xml b/attachment-viewer/src/main/res/layout/item_image_attachment.xml new file mode 100644 index 0000000000..91a009df2a --- /dev/null +++ b/attachment-viewer/src/main/res/layout/item_image_attachment.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/attachment-viewer/src/main/res/layout/item_video_attachment.xml b/attachment-viewer/src/main/res/layout/item_video_attachment.xml new file mode 100644 index 0000000000..29f01650fd --- /dev/null +++ b/attachment-viewer/src/main/res/layout/item_video_attachment.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + diff --git a/attachment-viewer/src/main/res/layout/view_image_attachment.xml b/attachment-viewer/src/main/res/layout/view_image_attachment.xml new file mode 100644 index 0000000000..3518a4472d --- /dev/null +++ b/attachment-viewer/src/main/res/layout/view_image_attachment.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index af3952b2d3..47b3ab240d 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,8 @@ allprojects { includeGroupByRegex "com\\.github\\.yalantis" // JsonViewer includeGroupByRegex 'com\\.github\\.BillCarsonFr' + // PhotoView + includeGroupByRegex 'com\\.github\\.chrisbanes' } } maven { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt index a69127532e..2353fc1c30 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt @@ -39,4 +39,6 @@ interface TimelineService { fun getTimeLineEvent(eventId: String): TimelineEvent? fun getTimeLineEventLive(eventId: String): LiveData> + + fun getAttachmentMessages() : List } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt index 5723568197..32160a96eb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt @@ -21,19 +21,25 @@ import androidx.lifecycle.Transformations import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.api.session.events.model.isImageMessage +import im.vector.matrix.android.api.session.events.model.isVideoMessage import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineService import im.vector.matrix.android.api.session.room.timeline.TimelineSettings import im.vector.matrix.android.api.util.Optional import im.vector.matrix.android.api.util.toOptional +import im.vector.matrix.android.internal.crypto.store.db.doWithRealm import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper import im.vector.matrix.android.internal.database.mapper.TimelineEventMapper import im.vector.matrix.android.internal.database.model.TimelineEventEntity +import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.di.SessionDatabase import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.util.fetchCopyMap +import io.realm.Sort +import io.realm.kotlin.where import org.greenrobot.eventbus.EventBus internal class DefaultTimelineService @AssistedInject constructor(@Assisted private val roomId: String, @@ -73,10 +79,10 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv override fun getTimeLineEvent(eventId: String): TimelineEvent? { return monarchy .fetchCopyMap({ - TimelineEventEntity.where(it, roomId = roomId, eventId = eventId).findFirst() - }, { entity, _ -> - timelineEventMapper.map(entity) - }) + TimelineEventEntity.where(it, roomId = roomId, eventId = eventId).findFirst() + }, { entity, _ -> + timelineEventMapper.map(entity) + }) } override fun getTimeLineEventLive(eventId: String): LiveData> { @@ -88,4 +94,16 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv events.firstOrNull().toOptional() } } + + override fun getAttachmentMessages(): List { + // TODO pretty bad query.. maybe we should denormalize clear type in base? + return doWithRealm(monarchy.realmConfiguration) { realm -> + realm.where() + .equalTo(TimelineEventEntityFields.ROOM_ID, roomId) + .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) + .findAll() + ?.mapNotNull { timelineEventMapper.map(it).takeIf { it.root.isImageMessage() || it.root.isVideoMessage() } } + ?: emptyList() + } + } } diff --git a/settings.gradle b/settings.gradle index 04307e89d9..76a15a206d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,6 @@ -include ':vector', ':matrix-sdk-android', ':matrix-sdk-android-rx', ':diff-match-patch' +include ':vector' +include ':matrix-sdk-android' +include ':matrix-sdk-android-rx' +include ':diff-match-patch' +include ':attachment-viewer' include ':multipicker' diff --git a/vector/build.gradle b/vector/build.gradle index 59ae3d35de..b409a7d8b8 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -279,6 +279,7 @@ dependencies { implementation project(":matrix-sdk-android-rx") implementation project(":diff-match-patch") implementation project(":multipicker") + implementation project(":attachment-viewer") implementation 'com.android.support:multidex:1.0.3' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" @@ -368,6 +369,10 @@ dependencies { implementation "com.github.piasy:GlideImageLoader:$big_image_viewer_version" implementation "com.github.piasy:ProgressPieIndicator:$big_image_viewer_version" implementation "com.github.piasy:GlideImageViewFactory:$big_image_viewer_version" + + // implementation 'com.github.MikeOrtiz:TouchImageView:3.0.2' + implementation 'com.github.chrisbanes:PhotoView:2.0.0' + implementation "com.github.bumptech.glide:glide:$glide_version" kapt "com.github.bumptech.glide:compiler:$glide_version" implementation 'com.danikula:videocache:2.7.1' diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index f9b78db17c..155c3bcd64 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -85,6 +85,11 @@ + + + BillCarsonFr/JsonViewer +
  • + Copyright (C) 2018 stfalcon.com +
  •  Apache License
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    index ceb276614a..6cf555b32d 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    @@ -48,6 +48,7 @@ import im.vector.riotx.features.invite.InviteUsersToRoomActivity
     import im.vector.riotx.features.invite.VectorInviteView
     import im.vector.riotx.features.link.LinkHandlerActivity
     import im.vector.riotx.features.login.LoginActivity
    +import im.vector.riotx.features.media.VectorAttachmentViewerActivity
     import im.vector.riotx.features.media.BigImageViewerActivity
     import im.vector.riotx.features.media.ImageMediaViewerActivity
     import im.vector.riotx.features.media.VideoMediaViewerActivity
    @@ -135,6 +136,7 @@ interface ScreenComponent {
         fun inject(activity: ReviewTermsActivity)
         fun inject(activity: WidgetActivity)
         fun inject(activity: VectorCallActivity)
    +    fun inject(activity: VectorAttachmentViewerActivity)
     
         /* ==========================================================================================
          * BottomSheets
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    index d38a26c099..938ae6a1bb 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    @@ -1171,14 +1171,27 @@ class RoomDetailFragment @Inject constructor(
         }
     
         override fun onImageMessageClicked(messageImageContent: MessageImageInfoContent, mediaData: ImageContentRenderer.Data, view: View) {
    -        navigator.openImageViewer(requireActivity(), mediaData, view) { pairs ->
    +        navigator.openMediaViewer(
    +                activity = requireActivity(),
    +                roomId = roomDetailArgs.roomId,
    +                mediaData = mediaData,
    +                view = view
    +        ) { pairs ->
                 pairs.add(Pair(roomToolbar, ViewCompat.getTransitionName(roomToolbar) ?: ""))
                 pairs.add(Pair(composerLayout, ViewCompat.getTransitionName(composerLayout) ?: ""))
             }
         }
     
         override fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) {
    -        navigator.openVideoViewer(requireActivity(), mediaData)
    +        navigator.openMediaViewer(
    +                activity = requireActivity(),
    +                roomId = roomDetailArgs.roomId,
    +                mediaData = mediaData,
    +                view = view
    +        ) { pairs ->
    +            pairs.add(Pair(roomToolbar, ViewCompat.getTransitionName(roomToolbar) ?: ""))
    +            pairs.add(Pair(composerLayout, ViewCompat.getTransitionName(composerLayout) ?: ""))
    +        }
         }
     
     //    override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) {
    @@ -1196,7 +1209,7 @@ class RoomDetailFragment @Inject constructor(
         override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
             if (allGranted(grantResults)) {
                 when (requestCode) {
    -                SAVE_ATTACHEMENT_REQUEST_CODE -> {
    +                SAVE_ATTACHEMENT_REQUEST_CODE           -> {
                         sharedActionViewModel.pendingAction?.let {
                             handleActions(it)
                             sharedActionViewModel.pendingAction = null
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    index 2174556098..4f5f34cbf0 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    @@ -337,7 +337,7 @@ class MessageItemFactory @Inject constructor(
                     .playable(true)
                     .highlighted(highlight)
                     .mediaData(thumbnailData)
    -                .clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view) }
    +                .clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view.findViewById(R.id.messageThumbnailView)) }
         }
     
         private fun buildItemForTextContent(messageContent: MessageTextContent,
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/AttachmentOverlayView.kt b/vector/src/main/java/im/vector/riotx/features/media/AttachmentOverlayView.kt
    new file mode 100644
    index 0000000000..2812b011f9
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/media/AttachmentOverlayView.kt
    @@ -0,0 +1,107 @@
    +/*
    + * Copyright (c) 2020 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 im.vector.riotx.features.media
    +
    +import android.content.Context
    +import android.graphics.Color
    +import android.util.AttributeSet
    +import android.view.View
    +import android.widget.ImageView
    +import android.widget.SeekBar
    +import android.widget.TextView
    +import androidx.constraintlayout.widget.ConstraintLayout
    +import androidx.constraintlayout.widget.Group
    +import im.vector.riotx.R
    +import im.vector.riotx.attachmentviewer.AttachmentEventListener
    +import im.vector.riotx.attachmentviewer.AttachmentEvents
    +
    +class AttachmentOverlayView @JvmOverloads constructor(
    +        context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    +) : ConstraintLayout(context, attrs, defStyleAttr), AttachmentEventListener {
    +
    +    var onShareCallback: (() -> Unit)? = null
    +    var onBack: (() -> Unit)? = null
    +    var onPlayPause: ((play: Boolean) -> Unit)? = null
    +    var videoSeekTo: ((progress: Int) -> Unit)? = null
    +
    +    private val counterTextView: TextView
    +    private val infoTextView: TextView
    +    private val shareImage: ImageView
    +    private val overlayPlayPauseButton: ImageView
    +    private val overlaySeekBar: SeekBar
    +
    +    var isPlaying = false
    +
    +    val videoControlsGroup: Group
    +
    +    var suspendSeekBarUpdate = false
    +
    +    init {
    +        View.inflate(context, R.layout.merge_image_attachment_overlay, this)
    +        setBackgroundColor(Color.TRANSPARENT)
    +        counterTextView = findViewById(R.id.overlayCounterText)
    +        infoTextView = findViewById(R.id.overlayInfoText)
    +        shareImage = findViewById(R.id.overlayShareButton)
    +        videoControlsGroup = findViewById(R.id.overlayVideoControlsGroup)
    +        overlayPlayPauseButton = findViewById(R.id.overlayPlayPauseButton)
    +        overlaySeekBar = findViewById(R.id.overlaySeekBar)
    +        findViewById(R.id.overlayBackButton).setOnClickListener {
    +            onBack?.invoke()
    +        }
    +        findViewById(R.id.overlayShareButton).setOnClickListener {
    +            onShareCallback?.invoke()
    +        }
    +        findViewById(R.id.overlayPlayPauseButton).setOnClickListener {
    +            onPlayPause?.invoke(!isPlaying)
    +        }
    +
    +        overlaySeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
    +            override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
    +                if (fromUser) {
    +                    videoSeekTo?.invoke(progress)
    +                }
    +            }
    +
    +            override fun onStartTrackingTouch(seekBar: SeekBar?) {
    +                suspendSeekBarUpdate = true
    +            }
    +
    +            override fun onStopTrackingTouch(seekBar: SeekBar?) {
    +                suspendSeekBarUpdate = false
    +            }
    +        })
    +    }
    +
    +    fun updateWith(counter: String, senderInfo: String) {
    +        counterTextView.text = counter
    +        infoTextView.text = senderInfo
    +    }
    +
    +    override fun onEvent(event: AttachmentEvents) {
    +        when (event) {
    +            is AttachmentEvents.VideoEvent -> {
    +                overlayPlayPauseButton.setImageResource(if (!event.isPlaying) R.drawable.ic_play_arrow else R.drawable.ic_pause)
    +                if (!suspendSeekBarUpdate) {
    +                    val safeDuration = (if (event.duration == 0) 100 else event.duration).toFloat()
    +                    val percent = ((event.progress / safeDuration) * 100f).toInt().coerceAtMost(100)
    +                    isPlaying = event.isPlaying
    +                    overlaySeekBar.progress = percent
    +                }
    +            }
    +        }
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/BaseAttachmentProvider.kt b/vector/src/main/java/im/vector/riotx/features/media/BaseAttachmentProvider.kt
    new file mode 100644
    index 0000000000..d4c41c7cb3
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/media/BaseAttachmentProvider.kt
    @@ -0,0 +1,148 @@
    +/*
    + * Copyright (c) 2020 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 im.vector.riotx.features.media
    +
    +import android.content.Context
    +import android.graphics.drawable.Drawable
    +import android.view.View
    +import android.widget.ImageView
    +import com.bumptech.glide.request.target.CustomViewTarget
    +import com.bumptech.glide.request.transition.Transition
    +import im.vector.matrix.android.api.MatrixCallback
    +import im.vector.matrix.android.api.session.file.FileService
    +import im.vector.riotx.attachmentviewer.AttachmentInfo
    +import im.vector.riotx.attachmentviewer.AttachmentSourceProvider
    +import im.vector.riotx.attachmentviewer.ImageLoaderTarget
    +import im.vector.riotx.attachmentviewer.VideoLoaderTarget
    +import java.io.File
    +
    +abstract class BaseAttachmentProvider(val imageContentRenderer: ImageContentRenderer, val fileService: FileService) : AttachmentSourceProvider {
    +
    +    interface InteractionListener {
    +        fun onDismissTapped()
    +        fun onShareTapped()
    +        fun onPlayPause(play: Boolean)
    +        fun videoSeekTo(percent: Int)
    +    }
    +
    +    var interactionListener: InteractionListener? = null
    +
    +    protected var overlayView: AttachmentOverlayView? = null
    +
    +    override fun overlayViewAtPosition(context: Context, position: Int): View? {
    +        if (position == -1) return null
    +        if (overlayView == null) {
    +            overlayView = AttachmentOverlayView(context)
    +            overlayView?.onBack = {
    +                interactionListener?.onDismissTapped()
    +            }
    +            overlayView?.onShareCallback = {
    +                interactionListener?.onShareTapped()
    +            }
    +            overlayView?.onPlayPause = { play ->
    +                interactionListener?.onPlayPause(play)
    +            }
    +            overlayView?.videoSeekTo = { percent ->
    +                interactionListener?.videoSeekTo(percent)
    +            }
    +        }
    +        return overlayView
    +    }
    +
    +    override fun loadImage(target: ImageLoaderTarget, info: AttachmentInfo.Image) {
    +        (info.data as? ImageContentRenderer.Data)?.let {
    +            imageContentRenderer.render(it, target.contextView(), object : CustomViewTarget(target.contextView()) {
    +                override fun onLoadFailed(errorDrawable: Drawable?) {
    +                    target.onLoadFailed(info.uid, errorDrawable)
    +                }
    +
    +                override fun onResourceCleared(placeholder: Drawable?) {
    +                    target.onResourceCleared(info.uid, placeholder)
    +                }
    +
    +                override fun onResourceReady(resource: Drawable, transition: Transition?) {
    +                    target.onResourceReady(info.uid, resource)
    +                }
    +            })
    +        }
    +    }
    +
    +    override fun loadImage(target: ImageLoaderTarget, info: AttachmentInfo.AnimatedImage) {
    +        (info.data as? ImageContentRenderer.Data)?.let {
    +            imageContentRenderer.render(it, target.contextView(), object : CustomViewTarget(target.contextView()) {
    +                override fun onLoadFailed(errorDrawable: Drawable?) {
    +                    target.onLoadFailed(info.uid, errorDrawable)
    +                }
    +
    +                override fun onResourceCleared(placeholder: Drawable?) {
    +                    target.onResourceCleared(info.uid, placeholder)
    +                }
    +
    +                override fun onResourceReady(resource: Drawable, transition: Transition?) {
    +                    target.onResourceReady(info.uid, resource)
    +                }
    +            })
    +        }
    +    }
    +
    +    override fun loadVideo(target: VideoLoaderTarget, info: AttachmentInfo.Video) {
    +        val data = info.data as? VideoContentRenderer.Data ?: return
    +//        videoContentRenderer.render(data,
    +//                holder.thumbnailImage,
    +//                holder.loaderProgressBar,
    +//                holder.videoView,
    +//                holder.errorTextView)
    +        imageContentRenderer.render(data.thumbnailMediaData, target.contextView(), object : CustomViewTarget(target.contextView()) {
    +            override fun onLoadFailed(errorDrawable: Drawable?) {
    +                target.onThumbnailLoadFailed(info.uid, errorDrawable)
    +            }
    +
    +            override fun onResourceCleared(placeholder: Drawable?) {
    +                target.onThumbnailResourceCleared(info.uid, placeholder)
    +            }
    +
    +            override fun onResourceReady(resource: Drawable, transition: Transition?) {
    +                target.onThumbnailResourceReady(info.uid, resource)
    +            }
    +        })
    +
    +        target.onVideoFileLoading(info.uid)
    +        fileService.downloadFile(
    +                downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
    +                id = data.eventId,
    +                mimeType = data.mimeType,
    +                elementToDecrypt = data.elementToDecrypt,
    +                fileName = data.filename,
    +                url = data.url,
    +                callback = object : MatrixCallback {
    +                    override fun onSuccess(data: File) {
    +                        target.onVideoFileReady(info.uid, data)
    +                    }
    +
    +                    override fun onFailure(failure: Throwable) {
    +                        target.onVideoFileLoadFailed(info.uid)
    +                    }
    +                }
    +        )
    +    }
    +
    +    override fun clear(id: String) {
    +        // TODO("Not yet implemented")
    +    }
    +
    +    abstract fun getFileForSharing(position: Int, callback: ((File?) -> Unit))
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/DataAttachmentRoomProvider.kt b/vector/src/main/java/im/vector/riotx/features/media/DataAttachmentRoomProvider.kt
    new file mode 100644
    index 0000000000..cb0039fc7e
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/media/DataAttachmentRoomProvider.kt
    @@ -0,0 +1,112 @@
    +/*
    + * Copyright (c) 2020 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 im.vector.riotx.features.media
    +
    +import android.content.Context
    +import android.view.View
    +import androidx.core.view.isVisible
    +import im.vector.matrix.android.api.MatrixCallback
    +import im.vector.matrix.android.api.session.events.model.isVideoMessage
    +import im.vector.matrix.android.api.session.file.FileService
    +import im.vector.matrix.android.api.session.room.Room
    +import im.vector.riotx.attachmentviewer.AttachmentInfo
    +import im.vector.riotx.core.date.VectorDateFormatter
    +import im.vector.riotx.core.extensions.localDateTime
    +import java.io.File
    +
    +class DataAttachmentRoomProvider(
    +        private val attachments: List,
    +        private val room: Room?,
    +        private val initialIndex: Int,
    +        imageContentRenderer: ImageContentRenderer,
    +        private val dateFormatter: VectorDateFormatter,
    +        fileService: FileService) : BaseAttachmentProvider(imageContentRenderer, fileService) {
    +
    +    override fun getItemCount(): Int = attachments.size
    +
    +    override fun getAttachmentInfoAt(position: Int): AttachmentInfo {
    +        return attachments[position].let {
    +            when (it) {
    +                is ImageContentRenderer.Data -> {
    +                    if (it.mimeType == "image/gif") {
    +                        AttachmentInfo.AnimatedImage(
    +                                uid = it.eventId,
    +                                url = it.url ?: "",
    +                                data = it
    +                        )
    +                    } else {
    +                        AttachmentInfo.Image(
    +                                uid = it.eventId,
    +                                url = it.url ?: "",
    +                                data = it
    +                        )
    +                    }
    +                }
    +                is VideoContentRenderer.Data -> {
    +                    AttachmentInfo.Video(
    +                            uid = it.eventId,
    +                            url = it.url ?: "",
    +                            data = it,
    +                            thumbnail = AttachmentInfo.Image(
    +                                    uid = it.eventId,
    +                                    url = it.thumbnailMediaData.url ?: "",
    +                                    data = it.thumbnailMediaData
    +                            )
    +                    )
    +                }
    +                else                         -> throw IllegalArgumentException()
    +            }
    +        }
    +    }
    +
    +    override fun overlayViewAtPosition(context: Context, position: Int): View? {
    +        super.overlayViewAtPosition(context, position)
    +        val item = attachments[position]
    +        val timeLineEvent = room?.getTimeLineEvent(item.eventId)
    +        if (timeLineEvent != null) {
    +            val dateString = timeLineEvent.root.localDateTime().let {
    +                "${dateFormatter.formatMessageDay(it)} at ${dateFormatter.formatMessageHour(it)} "
    +            }
    +            overlayView?.updateWith("${position + 1} of ${attachments.size}", "${timeLineEvent.senderInfo.displayName} $dateString")
    +            overlayView?.videoControlsGroup?.isVisible = timeLineEvent.root.isVideoMessage()
    +        } else {
    +            overlayView?.updateWith("", "")
    +        }
    +        return overlayView
    +    }
    +
    +    override fun getFileForSharing(position: Int, callback: (File?) -> Unit) {
    +        val item = attachments[position]
    +        fileService.downloadFile(
    +                downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    +                id = item.eventId,
    +                fileName = item.filename,
    +                mimeType = item.mimeType,
    +                url = item.url ?: "",
    +                elementToDecrypt = item.elementToDecrypt,
    +                callback = object : MatrixCallback {
    +                    override fun onSuccess(data: File) {
    +                        callback(data)
    +                    }
    +
    +                    override fun onFailure(failure: Throwable) {
    +                        callback(null)
    +                    }
    +                }
    +        )
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt b/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt
    index eeeb55ed15..f7613855c5 100644
    --- a/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt
    @@ -19,11 +19,13 @@ package im.vector.riotx.features.media
     import android.graphics.drawable.Drawable
     import android.net.Uri
     import android.os.Parcelable
    +import android.view.View
     import android.widget.ImageView
     import com.bumptech.glide.load.DataSource
     import com.bumptech.glide.load.engine.GlideException
     import com.bumptech.glide.load.resource.bitmap.RoundedCorners
     import com.bumptech.glide.request.RequestListener
    +import com.bumptech.glide.request.target.CustomViewTarget
     import com.bumptech.glide.request.target.Target
     import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.ORIENTATION_USE_EXIF
     import com.github.piasy.biv.view.BigImageView
    @@ -42,21 +44,29 @@ import java.io.File
     import javax.inject.Inject
     import kotlin.math.min
     
    +interface AttachmentData : Parcelable {
    +    val eventId: String
    +    val filename: String
    +    val mimeType: String?
    +    val url: String?
    +    val elementToDecrypt: ElementToDecrypt?
    +}
    +
     class ImageContentRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder,
                                                    private val dimensionConverter: DimensionConverter) {
     
         @Parcelize
         data class Data(
    -            val eventId: String,
    -            val filename: String,
    -            val mimeType: String?,
    -            val url: String?,
    -            val elementToDecrypt: ElementToDecrypt?,
    +            override val eventId: String,
    +            override val filename: String,
    +            override val mimeType: String?,
    +            override val url: String?,
    +            override val elementToDecrypt: ElementToDecrypt?,
                 val height: Int?,
                 val maxHeight: Int,
                 val width: Int?,
                 val maxWidth: Int
    -    ) : Parcelable {
    +    ) : AttachmentData {
     
             fun isLocalFile() = url.isLocalFile()
         }
    @@ -93,6 +103,25 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
                     .into(imageView)
         }
     
    +    fun render(data: Data, contextView: View, target: CustomViewTarget<*, Drawable>) {
    +        val req = if (data.elementToDecrypt != null) {
    +            // Encrypted image
    +            GlideApp
    +                    .with(contextView)
    +                    .load(data)
    +        } else {
    +            // Clear image
    +            val resolvedUrl = activeSessionHolder.getActiveSession().contentUrlResolver().resolveFullSize(data.url)
    +            GlideApp
    +                    .with(contextView)
    +                    .load(resolvedUrl)
    +        }
    +
    +        req.override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
    +                .fitCenter()
    +                .into(target)
    +    }
    +
         fun renderFitTarget(data: Data, mode: Mode, imageView: ImageView, callback: ((Boolean) -> Unit)? = null) {
             val size = processSize(data, mode)
     
    @@ -122,6 +151,45 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
                     .into(imageView)
         }
     
    +    fun renderThumbnailDontTransform(data: Data, imageView: ImageView, callback: ((Boolean) -> Unit)? = null) {
    +        // a11y
    +        imageView.contentDescription = data.filename
    +
    +        val req = if (data.elementToDecrypt != null) {
    +            // Encrypted image
    +            GlideApp
    +                    .with(imageView)
    +                    .load(data)
    +        } else {
    +            // Clear image
    +            val resolvedUrl = activeSessionHolder.getActiveSession().contentUrlResolver().resolveFullSize(data.url)
    +            GlideApp
    +                    .with(imageView)
    +                    .load(resolvedUrl)
    +        }
    +
    +        req.listener(object : RequestListener {
    +            override fun onLoadFailed(e: GlideException?,
    +                                      model: Any?,
    +                                      target: Target?,
    +                                      isFirstResource: Boolean): Boolean {
    +                callback?.invoke(false)
    +                return false
    +            }
    +
    +            override fun onResourceReady(resource: Drawable?,
    +                                         model: Any?,
    +                                         target: Target?,
    +                                         dataSource: DataSource?,
    +                                         isFirstResource: Boolean): Boolean {
    +                callback?.invoke(true)
    +                return false
    +            }
    +        })
    +                .dontTransform()
    +                .into(imageView)
    +    }
    +
         private fun createGlideRequest(data: Data, mode: Mode, imageView: ImageView, size: Size): GlideRequest {
             return if (data.elementToDecrypt != null) {
                 // Encrypted image
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt b/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt
    index 092199759f..8a6c2f7545 100644
    --- a/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt
    @@ -91,6 +91,8 @@ class ImageMediaViewerActivity : VectorBaseActivity() {
                 encryptedImageView.isVisible = false
                 // Postpone transaction a bit until thumbnail is loaded
                 supportPostponeEnterTransition()
    +
    +            // We are not passing the exact same image that in the
                 imageContentRenderer.renderFitTarget(mediaData, ImageContentRenderer.Mode.THUMBNAIL, imageTransitionView) {
                     // Proceed with transaction
                     scheduleStartPostponedTransition(imageTransitionView)
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/RoomEventsAttachmentProvider.kt b/vector/src/main/java/im/vector/riotx/features/media/RoomEventsAttachmentProvider.kt
    new file mode 100644
    index 0000000000..7a7fea6dc4
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/media/RoomEventsAttachmentProvider.kt
    @@ -0,0 +1,175 @@
    +/*
    + * Copyright (c) 2020 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 im.vector.riotx.features.media
    +
    +import android.content.Context
    +import android.view.View
    +import androidx.core.view.isVisible
    +import im.vector.matrix.android.api.MatrixCallback
    +import im.vector.matrix.android.api.session.Session
    +import im.vector.matrix.android.api.session.events.model.isVideoMessage
    +import im.vector.matrix.android.api.session.events.model.toModel
    +import im.vector.matrix.android.api.session.file.FileService
    +import im.vector.matrix.android.api.session.room.Room
    +import im.vector.matrix.android.api.session.room.model.message.MessageContent
    +import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
    +import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
    +import im.vector.matrix.android.api.session.room.model.message.MessageWithAttachmentContent
    +import im.vector.matrix.android.api.session.room.model.message.getFileUrl
    +import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
    +import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
    +import im.vector.riotx.attachmentviewer.AttachmentInfo
    +import im.vector.riotx.core.date.VectorDateFormatter
    +import im.vector.riotx.core.extensions.localDateTime
    +import java.io.File
    +import javax.inject.Inject
    +
    +class RoomEventsAttachmentProvider(
    +        private val attachments: List,
    +        private val initialIndex: Int,
    +        imageContentRenderer: ImageContentRenderer,
    +        private val dateFormatter: VectorDateFormatter,
    +        fileService: FileService
    +) : BaseAttachmentProvider(imageContentRenderer, fileService) {
    +
    +    override fun getItemCount(): Int {
    +        return attachments.size
    +    }
    +
    +    override fun getAttachmentInfoAt(position: Int): AttachmentInfo {
    +        return attachments[position].let {
    +            val content = it.root.getClearContent().toModel() as? MessageWithAttachmentContent
    +            if (content is MessageImageContent) {
    +                val data = ImageContentRenderer.Data(
    +                        eventId = it.eventId,
    +                        filename = content.body,
    +                        mimeType = content.mimeType,
    +                        url = content.getFileUrl(),
    +                        elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(),
    +                        maxHeight = -1,
    +                        maxWidth = -1,
    +                        width = null,
    +                        height = null
    +                )
    +                if (content.mimeType == "image/gif") {
    +                    AttachmentInfo.AnimatedImage(
    +                            uid = it.eventId,
    +                            url = content.url ?: "",
    +                            data = data
    +                    )
    +                } else {
    +                    AttachmentInfo.Image(
    +                            uid = it.eventId,
    +                            url = content.url ?: "",
    +                            data = data
    +                    )
    +                }
    +            } else if (content is MessageVideoContent) {
    +                val thumbnailData = ImageContentRenderer.Data(
    +                        eventId = it.eventId,
    +                        filename = content.body,
    +                        mimeType = content.mimeType,
    +                        url = content.videoInfo?.thumbnailFile?.url
    +                                ?: content.videoInfo?.thumbnailUrl,
    +                        elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(),
    +                        height = content.videoInfo?.height,
    +                        maxHeight = -1,
    +                        width = content.videoInfo?.width,
    +                        maxWidth = -1
    +                )
    +                val data = VideoContentRenderer.Data(
    +                        eventId = it.eventId,
    +                        filename = content.body,
    +                        mimeType = content.mimeType,
    +                        url = content.getFileUrl(),
    +                        elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(),
    +                        thumbnailMediaData = thumbnailData
    +                )
    +                AttachmentInfo.Video(
    +                        uid = it.eventId,
    +                        url = content.getFileUrl() ?: "",
    +                        data = data,
    +                        thumbnail = AttachmentInfo.Image(
    +                                uid = it.eventId,
    +                                url = content.videoInfo?.thumbnailFile?.url
    +                                        ?: content.videoInfo?.thumbnailUrl ?: "",
    +                                data = thumbnailData
    +
    +                        )
    +                )
    +            } else {
    +                AttachmentInfo.Image(
    +                        uid = it.eventId,
    +                        url = "",
    +                        data = null
    +                )
    +            }
    +        }
    +    }
    +
    +    override fun overlayViewAtPosition(context: Context, position: Int): View? {
    +        super.overlayViewAtPosition(context, position)
    +        val item = attachments[position]
    +        val dateString = item.root.localDateTime().let {
    +            "${dateFormatter.formatMessageDay(it)} at ${dateFormatter.formatMessageHour(it)} "
    +        }
    +        overlayView?.updateWith("${position + 1} of ${attachments.size}", "${item.senderInfo.displayName} $dateString")
    +        overlayView?.videoControlsGroup?.isVisible = item.root.isVideoMessage()
    +        return overlayView
    +    }
    +
    +    override fun getFileForSharing(position: Int, callback: (File?) -> Unit) {
    +        attachments[position].let { timelineEvent ->
    +
    +            val messageContent = timelineEvent.root.getClearContent().toModel()
    +                    as? MessageWithAttachmentContent
    +                    ?: return@let
    +            fileService.downloadFile(
    +                    downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    +                    id = timelineEvent.eventId,
    +                    fileName = messageContent.body,
    +                    mimeType = messageContent.mimeType,
    +                    url = messageContent.getFileUrl(),
    +                    elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
    +                    callback = object : MatrixCallback {
    +                        override fun onSuccess(data: File) {
    +                           callback(data)
    +                        }
    +
    +                        override fun onFailure(failure: Throwable) {
    +                            callback(null)
    +                        }
    +                    }
    +            )
    +        }
    +    }
    +}
    +
    +class AttachmentProviderFactory @Inject constructor(
    +        private val imageContentRenderer: ImageContentRenderer,
    +        private val vectorDateFormatter: VectorDateFormatter,
    +        private val session: Session
    +) {
    +
    +    fun createProvider(attachments: List, initialIndex: Int): RoomEventsAttachmentProvider {
    +        return RoomEventsAttachmentProvider(attachments, initialIndex, imageContentRenderer, vectorDateFormatter, session.fileService())
    +    }
    +
    +    fun createProvider(attachments: List, room: Room?, initialIndex: Int): DataAttachmentRoomProvider {
    +        return DataAttachmentRoomProvider(attachments, room, initialIndex, imageContentRenderer, vectorDateFormatter, session.fileService())
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/VectorAttachmentViewerActivity.kt b/vector/src/main/java/im/vector/riotx/features/media/VectorAttachmentViewerActivity.kt
    new file mode 100644
    index 0000000000..38e3ccc69c
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/media/VectorAttachmentViewerActivity.kt
    @@ -0,0 +1,277 @@
    +/*
    + * Copyright (c) 2020 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 im.vector.riotx.features.media
    +
    +import android.content.Context
    +import android.content.Intent
    +import android.os.Bundle
    +import android.os.Parcelable
    +import android.view.View
    +import android.view.ViewTreeObserver
    +import androidx.core.app.ActivityCompat
    +import androidx.core.content.ContextCompat
    +import androidx.core.net.toUri
    +import androidx.core.transition.addListener
    +import androidx.core.view.ViewCompat
    +import androidx.core.view.isInvisible
    +import androidx.core.view.isVisible
    +import androidx.lifecycle.Lifecycle
    +import androidx.transition.Transition
    +import im.vector.riotx.R
    +import im.vector.riotx.attachmentviewer.AttachmentCommands
    +import im.vector.riotx.attachmentviewer.AttachmentViewerActivity
    +import im.vector.riotx.core.di.ActiveSessionHolder
    +import im.vector.riotx.core.di.DaggerScreenComponent
    +import im.vector.riotx.core.di.HasVectorInjector
    +import im.vector.riotx.core.di.ScreenComponent
    +import im.vector.riotx.core.di.VectorComponent
    +import im.vector.riotx.core.intent.getMimeTypeFromUri
    +import im.vector.riotx.core.utils.shareMedia
    +import im.vector.riotx.features.themes.ActivityOtherThemes
    +import im.vector.riotx.features.themes.ThemeUtils
    +import kotlinx.android.parcel.Parcelize
    +import timber.log.Timber
    +import javax.inject.Inject
    +import kotlin.system.measureTimeMillis
    +
    +class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmentProvider.InteractionListener {
    +
    +    @Parcelize
    +    data class Args(
    +            val roomId: String?,
    +            val eventId: String,
    +            val sharedTransitionName: String?
    +    ) : Parcelable
    +
    +    @Inject
    +    lateinit var sessionHolder: ActiveSessionHolder
    +
    +    @Inject
    +    lateinit var dataSourceFactory: AttachmentProviderFactory
    +
    +    @Inject
    +    lateinit var imageContentRenderer: ImageContentRenderer
    +
    +    private lateinit var screenComponent: ScreenComponent
    +
    +    private var initialIndex = 0
    +    private var isAnimatingOut = false
    +
    +    var currentSourceProvider: BaseAttachmentProvider? = null
    +
    +    override fun onCreate(savedInstanceState: Bundle?) {
    +        super.onCreate(savedInstanceState)
    +        Timber.i("onCreate Activity ${this.javaClass.simpleName}")
    +        val vectorComponent = getVectorComponent()
    +        screenComponent = DaggerScreenComponent.factory().create(vectorComponent, this)
    +        val timeForInjection = measureTimeMillis {
    +            screenComponent.inject(this)
    +        }
    +        Timber.v("Injecting dependencies into ${javaClass.simpleName} took $timeForInjection ms")
    +        ThemeUtils.setActivityTheme(this, getOtherThemes())
    +
    +        val args = args() ?: throw IllegalArgumentException("Missing arguments")
    +
    +        if (savedInstanceState == null && addTransitionListener()) {
    +            args.sharedTransitionName?.let {
    +                ViewCompat.setTransitionName(imageTransitionView, it)
    +                transitionImageContainer.isVisible = true
    +
    +                // Postpone transaction a bit until thumbnail is loaded
    +                val mediaData: Parcelable? = intent.getParcelableExtra(EXTRA_IMAGE_DATA)
    +                if (mediaData is ImageContentRenderer.Data) {
    +                    // will be shown at end of transition
    +                    pager2.isInvisible = true
    +                    supportPostponeEnterTransition()
    +                    imageContentRenderer.renderThumbnailDontTransform(mediaData, imageTransitionView) {
    +                        // Proceed with transaction
    +                        scheduleStartPostponedTransition(imageTransitionView)
    +                    }
    +                } else if (mediaData is VideoContentRenderer.Data) {
    +                    // will be shown at end of transition
    +                    pager2.isInvisible = true
    +                    supportPostponeEnterTransition()
    +                    imageContentRenderer.renderThumbnailDontTransform(mediaData.thumbnailMediaData, imageTransitionView) {
    +                        // Proceed with transaction
    +                        scheduleStartPostponedTransition(imageTransitionView)
    +                    }
    +                }
    +            }
    +        }
    +
    +        val session = sessionHolder.getSafeActiveSession() ?: return Unit.also { finish() }
    +
    +        val room = args.roomId?.let { session.getRoom(it) }
    +
    +        val inMemoryData = intent.getParcelableArrayListExtra(EXTRA_IN_MEMORY_DATA)
    +        if (inMemoryData != null) {
    +            val sourceProvider = dataSourceFactory.createProvider(inMemoryData, room, initialIndex)
    +            val index = inMemoryData.indexOfFirst { it.eventId == args.eventId }
    +            initialIndex = index
    +            sourceProvider.interactionListener = this
    +            setSourceProvider(sourceProvider)
    +            this.currentSourceProvider = sourceProvider
    +            if (savedInstanceState == null) {
    +                pager2.setCurrentItem(index, false)
    +                // The page change listener is not notified of the change...
    +                pager2.post {
    +                    onSelectedPositionChanged(index)
    +                }
    +            }
    +        } else {
    +            val events = room?.getAttachmentMessages()
    +                    ?: emptyList()
    +            val index = events.indexOfFirst { it.eventId == args.eventId }
    +            initialIndex = index
    +
    +            val sourceProvider = dataSourceFactory.createProvider(events, index)
    +            sourceProvider.interactionListener = this
    +            setSourceProvider(sourceProvider)
    +            this.currentSourceProvider = sourceProvider
    +            if (savedInstanceState == null) {
    +                pager2.setCurrentItem(index, false)
    +                // The page change listener is not notified of the change...
    +                pager2.post {
    +                    onSelectedPositionChanged(index)
    +                }
    +            }
    +        }
    +
    +        window.statusBarColor = ContextCompat.getColor(this, R.color.black_alpha)
    +        window.navigationBarColor = ContextCompat.getColor(this, R.color.black_alpha)
    +    }
    +
    +    private fun getOtherThemes() = ActivityOtherThemes.VectorAttachmentsPreview
    +
    +    override fun shouldAnimateDismiss(): Boolean {
    +        return currentPosition != initialIndex
    +    }
    +
    +    override fun onBackPressed() {
    +        if (currentPosition == initialIndex) {
    +            // show back the transition view
    +            // TODO, we should track and update the mapping
    +            transitionImageContainer.isVisible = true
    +        }
    +        isAnimatingOut = true
    +        super.onBackPressed()
    +    }
    +
    +    override fun animateClose() {
    +        if (currentPosition == initialIndex) {
    +            // show back the transition view
    +            // TODO, we should track and update the mapping
    +            transitionImageContainer.isVisible = true
    +        }
    +        isAnimatingOut = true
    +        ActivityCompat.finishAfterTransition(this)
    +    }
    +
    +    // ==========================================================================================
    +    // PRIVATE METHODS
    +    // ==========================================================================================
    +
    +    /**
    +     * Try and add a [Transition.TransitionListener] to the entering shared element
    +     * [Transition]. We do this so that we can load the full-size image after the transition
    +     * has completed.
    +     *
    +     * @return true if we were successful in adding a listener to the enter transition
    +     */
    +    private fun addTransitionListener(): Boolean {
    +        val transition = window.sharedElementEnterTransition
    +
    +        if (transition != null) {
    +            // There is an entering shared element transition so add a listener to it
    +            transition.addListener(
    +                    onEnd = {
    +                        // The listener is also called when we are exiting
    +                        // so we use a boolean to avoid reshowing pager at end of dismiss transition
    +                        if (!isAnimatingOut) {
    +                            transitionImageContainer.isVisible = false
    +                            pager2.isInvisible = false
    +                        }
    +                    },
    +                    onCancel = {
    +                        if (!isAnimatingOut) {
    +                            transitionImageContainer.isVisible = false
    +                            pager2.isInvisible = false
    +                        }
    +                    }
    +            )
    +            return true
    +        }
    +
    +        // If we reach here then we have not added a listener
    +        return false
    +    }
    +
    +    private fun args() = intent.getParcelableExtra(EXTRA_ARGS)
    +
    +    private fun getVectorComponent(): VectorComponent {
    +        return (application as HasVectorInjector).injector()
    +    }
    +
    +    private fun scheduleStartPostponedTransition(sharedElement: View) {
    +        sharedElement.viewTreeObserver.addOnPreDrawListener(
    +                object : ViewTreeObserver.OnPreDrawListener {
    +                    override fun onPreDraw(): Boolean {
    +                        sharedElement.viewTreeObserver.removeOnPreDrawListener(this)
    +                        supportStartPostponedEnterTransition()
    +                        return true
    +                    }
    +                })
    +    }
    +
    +    companion object {
    +        const val EXTRA_ARGS = "EXTRA_ARGS"
    +        const val EXTRA_IMAGE_DATA = "EXTRA_IMAGE_DATA"
    +        const val EXTRA_IN_MEMORY_DATA = "EXTRA_IN_MEMORY_DATA"
    +
    +        fun newIntent(context: Context,
    +                      mediaData: AttachmentData,
    +                      roomId: String?,
    +                      eventId: String,
    +                      inMemoryData: List,
    +                      sharedTransitionName: String?) = Intent(context, VectorAttachmentViewerActivity::class.java).also {
    +            it.putExtra(EXTRA_ARGS, Args(roomId, eventId, sharedTransitionName))
    +            it.putExtra(EXTRA_IMAGE_DATA, mediaData)
    +            if (inMemoryData.isNotEmpty()) {
    +                it.putParcelableArrayListExtra(EXTRA_IN_MEMORY_DATA, ArrayList(inMemoryData))
    +            }
    +        }
    +    }
    +
    +    override fun onDismissTapped() {
    +        animateClose()
    +    }
    +
    +    override fun onPlayPause(play: Boolean) {
    +        handle(if (play) AttachmentCommands.StartVideo else AttachmentCommands.PauseVideo)
    +    }
    +
    +    override fun videoSeekTo(percent: Int) {
    +        handle(AttachmentCommands.SeekTo(percent))
    +    }
    +
    +    override fun onShareTapped() {
    +        this.currentSourceProvider?.getFileForSharing(currentPosition) { data ->
    +            if (data != null && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
    +                shareMedia(this@VectorAttachmentViewerActivity, data, getMimeTypeFromUri(this@VectorAttachmentViewerActivity, data.toUri()))
    +            }
    +        }
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt b/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt
    index 760d3b12a0..e6dec88349 100644
    --- a/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt
    @@ -16,7 +16,6 @@
     
     package im.vector.riotx.features.media
     
    -import android.os.Parcelable
     import android.widget.ImageView
     import android.widget.ProgressBar
     import android.widget.TextView
    @@ -38,13 +37,13 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
     
         @Parcelize
         data class Data(
    -            val eventId: String,
    -            val filename: String,
    -            val mimeType: String?,
    -            val url: String?,
    -            val elementToDecrypt: ElementToDecrypt?,
    +            override val eventId: String,
    +            override val filename: String,
    +            override val mimeType: String?,
    +            override val url: String?,
    +            override val elementToDecrypt: ElementToDecrypt?,
                 val thumbnailMediaData: ImageContentRenderer.Data
    -    ) : Parcelable
    +    ) : AttachmentData
     
         fun render(data: Data,
                    thumbnailView: ImageView,
    diff --git a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt
    index 83f0baa12c..ae73cb8dad 100644
    --- a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt
    @@ -19,7 +19,6 @@ package im.vector.riotx.features.navigation
     import android.app.Activity
     import android.content.Context
     import android.content.Intent
    -import android.os.Build
     import android.view.View
     import android.view.Window
     import androidx.core.app.ActivityOptionsCompat
    @@ -49,11 +48,9 @@ import im.vector.riotx.features.home.room.detail.RoomDetailArgs
     import im.vector.riotx.features.home.room.detail.widget.WidgetRequestCodes
     import im.vector.riotx.features.home.room.filtered.FilteredRoomsActivity
     import im.vector.riotx.features.invite.InviteUsersToRoomActivity
    +import im.vector.riotx.features.media.AttachmentData
     import im.vector.riotx.features.media.BigImageViewerActivity
    -import im.vector.riotx.features.media.ImageContentRenderer
    -import im.vector.riotx.features.media.ImageMediaViewerActivity
    -import im.vector.riotx.features.media.VideoContentRenderer
    -import im.vector.riotx.features.media.VideoMediaViewerActivity
    +import im.vector.riotx.features.media.VectorAttachmentViewerActivity
     import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity
     import im.vector.riotx.features.roomdirectory.createroom.CreateRoomActivity
     import im.vector.riotx.features.roomdirectory.roompreview.RoomPreviewActivity
    @@ -89,7 +86,8 @@ class DefaultNavigator @Inject constructor(
     
         override fun performDeviceVerification(context: Context, otherUserId: String, sasTransactionId: String) {
             val session = sessionHolder.getSafeActiveSession() ?: return
    -        val tx = session.cryptoService().verificationService().getExistingTransaction(otherUserId, sasTransactionId) ?: return
    +        val tx = session.cryptoService().verificationService().getExistingTransaction(otherUserId, sasTransactionId)
    +                ?: return
             (tx as? IncomingSasVerificationTransaction)?.performAccept()
             if (context is VectorBaseActivity) {
                 VerificationBottomSheet.withArgs(
    @@ -237,7 +235,8 @@ class DefaultNavigator @Inject constructor(
                     ?.let { avatarUrl ->
                         val intent = BigImageViewerActivity.newIntent(activity, matrixItem.getBestName(), avatarUrl)
                         val options = sharedElement?.let {
    -                        ActivityOptionsCompat.makeSceneTransitionAnimation(activity, it, ViewCompat.getTransitionName(it) ?: "")
    +                        ActivityOptionsCompat.makeSceneTransitionAnimation(activity, it, ViewCompat.getTransitionName(it)
    +                                ?: "")
                         }
                         activity.startActivity(intent, options?.toBundle())
                     }
    @@ -265,27 +264,32 @@ class DefaultNavigator @Inject constructor(
             context.startActivity(WidgetActivity.newIntent(context, widgetArgs))
         }
     
    -    override fun openImageViewer(activity: Activity, mediaData: ImageContentRenderer.Data, view: View, options: ((MutableList>) -> Unit)?) {
    -        val intent = ImageMediaViewerActivity.newIntent(activity, mediaData, ViewCompat.getTransitionName(view))
    -        val pairs = ArrayList>()
    -        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    +    override fun openMediaViewer(activity: Activity,
    +                                 roomId: String,
    +                                 mediaData: AttachmentData,
    +                                 view: View,
    +                                 inMemory: List,
    +                                 options: ((MutableList>) -> Unit)?) {
    +        VectorAttachmentViewerActivity.newIntent(activity,
    +                mediaData,
    +                roomId,
    +                mediaData.eventId,
    +                inMemory,
    +                ViewCompat.getTransitionName(view)).let { intent ->
    +            val pairs = ArrayList>()
                 activity.window.decorView.findViewById(android.R.id.statusBarBackground)?.let {
                     pairs.add(Pair(it, Window.STATUS_BAR_BACKGROUND_TRANSITION_NAME))
                 }
                 activity.window.decorView.findViewById(android.R.id.navigationBarBackground)?.let {
                     pairs.add(Pair(it, Window.NAVIGATION_BAR_BACKGROUND_TRANSITION_NAME))
                 }
    +
    +            pairs.add(Pair(view, ViewCompat.getTransitionName(view) ?: ""))
    +            options?.invoke(pairs)
    +
    +            val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, *pairs.toTypedArray()).toBundle()
    +            activity.startActivity(intent, bundle)
             }
    -        pairs.add(Pair(view, ViewCompat.getTransitionName(view) ?: ""))
    -        options?.invoke(pairs)
    -
    -        val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, *pairs.toTypedArray()).toBundle()
    -        activity.startActivity(intent, bundle)
    -    }
    -
    -    override fun openVideoViewer(activity: Activity, mediaData: VideoContentRenderer.Data) {
    -        val intent = VideoMediaViewerActivity.newIntent(activity, mediaData)
    -        activity.startActivity(intent)
         }
     
         private fun startActivity(context: Context, intent: Intent, buildTask: Boolean) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt
    index 3ead483369..56176f819a 100644
    --- a/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt
    @@ -23,11 +23,10 @@ import androidx.core.util.Pair
     import androidx.fragment.app.Fragment
     import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom
     import im.vector.matrix.android.api.session.terms.TermsService
    -import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.matrix.android.api.session.widgets.model.Widget
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.features.home.room.detail.widget.WidgetRequestCodes
    -import im.vector.riotx.features.media.ImageContentRenderer
    -import im.vector.riotx.features.media.VideoContentRenderer
    +import im.vector.riotx.features.media.AttachmentData
     import im.vector.riotx.features.settings.VectorSettingsActivity
     import im.vector.riotx.features.share.SharedData
     import im.vector.riotx.features.terms.ReviewTermsActivity
    @@ -93,7 +92,10 @@ interface Navigator {
     
         fun openRoomWidget(context: Context, roomId: String, widget: Widget)
     
    -    fun openImageViewer(activity: Activity, mediaData: ImageContentRenderer.Data, view: View, options: ((MutableList>) -> Unit)?)
    -
    -    fun openVideoViewer(activity: Activity, mediaData: VideoContentRenderer.Data)
    +    fun openMediaViewer(activity: Activity,
    +                        roomId: String,
    +                        mediaData: AttachmentData,
    +                        view: View,
    +                        inMemory: List = emptyList(),
    +                        options: ((MutableList>) -> Unit)?)
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt b/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt
    index 78a0cece41..e5b2f34f61 100644
    --- a/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt
    @@ -26,6 +26,7 @@ import com.tapadoo.alerter.Alerter
     import com.tapadoo.alerter.OnHideAlertListener
     import dagger.Lazy
     import im.vector.riotx.R
    +import im.vector.riotx.core.platform.VectorBaseActivity
     import im.vector.riotx.features.home.AvatarRenderer
     import im.vector.riotx.features.themes.ThemeUtils
     import timber.log.Timber
    @@ -83,7 +84,7 @@ class PopupAlertManager @Inject constructor(private val avatarRenderer: Lazy()
    +                ?.firstOrNull()
    +                ?.roomUploadsAppBar
         }
     
    -    override fun onOpenVideoClicked(view: View, mediaData: VideoContentRenderer.Data) {
    -        navigator.openVideoViewer(requireActivity(), mediaData)
    +    override fun onOpenImageClicked(view: View, mediaData: ImageContentRenderer.Data) = withState(uploadsViewModel) { state ->
    +        val inMemory = getItemsArgs(state)
    +        navigator.openMediaViewer(
    +                activity = requireActivity(),
    +                roomId = state.roomId,
    +                mediaData = mediaData,
    +                view = view,
    +                inMemory = inMemory
    +        ) { pairs ->
    +            trickFindAppBar()?.let {
    +                pairs.add(Pair(it, ViewCompat.getTransitionName(it) ?: ""))
    +            }
    +        }
    +    }
    +
    +    private fun getItemsArgs(state: RoomUploadsViewState): List {
    +        return state.mediaEvents.mapNotNull {
    +            when (val content = it.contentWithAttachmentContent) {
    +                is MessageImageContent -> {
    +                    ImageContentRenderer.Data(
    +                            eventId = it.eventId,
    +                            filename = content.body,
    +                            mimeType = content.mimeType,
    +                            url = content.getFileUrl(),
    +                            elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(),
    +                            maxHeight = -1,
    +                            maxWidth = -1,
    +                            width = null,
    +                            height = null
    +                    )
    +                }
    +                is MessageVideoContent -> {
    +                    val thumbnailData = ImageContentRenderer.Data(
    +                            eventId = it.eventId,
    +                            filename = content.body,
    +                            mimeType = content.mimeType,
    +                            url = content.videoInfo?.thumbnailFile?.url
    +                                    ?: content.videoInfo?.thumbnailUrl,
    +                            elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(),
    +                            height = content.videoInfo?.height,
    +                            maxHeight = -1,
    +                            width = content.videoInfo?.width,
    +                            maxWidth = -1
    +                    )
    +                    VideoContentRenderer.Data(
    +                            eventId = it.eventId,
    +                            filename = content.body,
    +                            mimeType = content.mimeType,
    +                            url = content.getFileUrl(),
    +                            elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(),
    +                            thumbnailMediaData = thumbnailData
    +                    )
    +                }
    +                else                   -> null
    +            }
    +        }
    +    }
    +
    +    override fun onOpenVideoClicked(view: View, mediaData: VideoContentRenderer.Data) = withState(uploadsViewModel) { state ->
    +        val inMemory = getItemsArgs(state)
    +        navigator.openMediaViewer(
    +                activity = requireActivity(),
    +                roomId = state.roomId,
    +                mediaData = mediaData,
    +                view = view,
    +                inMemory = inMemory
    +        ) { pairs ->
    +            trickFindAppBar()?.let {
    +                pairs.add(Pair(it, ViewCompat.getTransitionName(it) ?: ""))
    +            }
    +        }
         }
     
         override fun loadMore() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsImageItem.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsImageItem.kt
    index 98026901cc..3b83e99656 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsImageItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsImageItem.kt
    @@ -18,11 +18,13 @@ package im.vector.riotx.features.roomprofile.uploads.media
     
     import android.view.View
     import android.widget.ImageView
    +import androidx.core.view.ViewCompat
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    +import im.vector.riotx.core.utils.DebouncedClickListener
     import im.vector.riotx.features.media.ImageContentRenderer
     
     @EpoxyModelClass(layout = R.layout.item_uploads_image)
    @@ -35,8 +37,13 @@ abstract class UploadsImageItem : VectorEpoxyModel() {
     
         override fun bind(holder: Holder) {
             super.bind(holder)
    -        holder.view.setOnClickListener { listener?.onItemClicked(holder.imageView, data) }
    +        holder.view.setOnClickListener(
    +                DebouncedClickListener(View.OnClickListener { _ ->
    +                    listener?.onItemClicked(holder.imageView, data)
    +                })
    +        )
             imageContentRenderer.render(data, holder.imageView, IMAGE_SIZE_DP)
    +        ViewCompat.setTransitionName(holder.imageView, "imagePreview_${id()}")
         }
     
         class Holder : VectorEpoxyHolder() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsVideoItem.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsVideoItem.kt
    index 82e33b76da..f20f6ed5b1 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsVideoItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsVideoItem.kt
    @@ -18,11 +18,13 @@ package im.vector.riotx.features.roomprofile.uploads.media
     
     import android.view.View
     import android.widget.ImageView
    +import androidx.core.view.ViewCompat
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    +import im.vector.riotx.core.utils.DebouncedClickListener
     import im.vector.riotx.features.media.ImageContentRenderer
     import im.vector.riotx.features.media.VideoContentRenderer
     
    @@ -36,8 +38,13 @@ abstract class UploadsVideoItem : VectorEpoxyModel() {
     
         override fun bind(holder: Holder) {
             super.bind(holder)
    -        holder.view.setOnClickListener { listener?.onItemClicked(holder.imageView, data) }
    +        holder.view.setOnClickListener(
    +            DebouncedClickListener(View.OnClickListener { _ ->
    +                listener?.onItemClicked(holder.imageView, data)
    +            })
    +        )
             imageContentRenderer.render(data.thumbnailMediaData, holder.imageView, IMAGE_SIZE_DP)
    +        ViewCompat.setTransitionName(holder.imageView, "videoPreview_${id()}")
         }
     
         class Holder : VectorEpoxyHolder() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/themes/ActivityOtherThemes.kt b/vector/src/main/java/im/vector/riotx/features/themes/ActivityOtherThemes.kt
    index b37c1a4818..b29e60784e 100644
    --- a/vector/src/main/java/im/vector/riotx/features/themes/ActivityOtherThemes.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/themes/ActivityOtherThemes.kt
    @@ -38,4 +38,10 @@ sealed class ActivityOtherThemes(@StyleRes val dark: Int,
                 R.style.AppTheme_AttachmentsPreview,
                 R.style.AppTheme_AttachmentsPreview
         )
    +
    +    object VectorAttachmentsPreview : ActivityOtherThemes(
    +            R.style.AppTheme_Transparent,
    +            R.style.AppTheme_Transparent,
    +            R.style.AppTheme_Transparent
    +    )
     }
    diff --git a/vector/src/main/res/drawable/ic_pause.xml b/vector/src/main/res/drawable/ic_pause.xml
    new file mode 100644
    index 0000000000..13d6d2ec00
    --- /dev/null
    +++ b/vector/src/main/res/drawable/ic_pause.xml
    @@ -0,0 +1,10 @@
    +
    +  
    +
    diff --git a/vector/src/main/res/drawable/ic_play_arrow.xml b/vector/src/main/res/drawable/ic_play_arrow.xml
    new file mode 100644
    index 0000000000..13c137a921
    --- /dev/null
    +++ b/vector/src/main/res/drawable/ic_play_arrow.xml
    @@ -0,0 +1,10 @@
    +
    +  
    +
    diff --git a/vector/src/main/res/layout/fragment_room_uploads.xml b/vector/src/main/res/layout/fragment_room_uploads.xml
    index 5e289d4724..f5d3658ee5 100644
    --- a/vector/src/main/res/layout/fragment_room_uploads.xml
    +++ b/vector/src/main/res/layout/fragment_room_uploads.xml
    @@ -8,6 +8,8 @@
     
         
    diff --git a/vector/src/main/res/layout/merge_image_attachment_overlay.xml b/vector/src/main/res/layout/merge_image_attachment_overlay.xml
    new file mode 100644
    index 0000000000..b0e769579c
    --- /dev/null
    +++ b/vector/src/main/res/layout/merge_image_attachment_overlay.xml
    @@ -0,0 +1,133 @@
    +
    +
    +
    +    
    +
    +    
    +
    +
    +    
    +
    +    
    +
    +    
    +
    +    
    +
    +
    +    
    +
    +    
    +
    +    
    +
    +    
    +
    diff --git a/vector/src/main/res/values/colors_riotx.xml b/vector/src/main/res/values/colors_riotx.xml
    index a9cb32c3fd..c9d1c2a223 100644
    --- a/vector/src/main/res/values/colors_riotx.xml
    +++ b/vector/src/main/res/values/colors_riotx.xml
    @@ -40,6 +40,7 @@
         
         #FF000000
         #FFFFFFFF
    +    #55000000
     
         
         Ongoing conference call.\nJoin as %1$s or %2$s
         Voice
    diff --git a/vector/src/main/res/values/theme_common.xml b/vector/src/main/res/values/theme_common.xml
    index 151d97c097..414d562ff0 100644
    --- a/vector/src/main/res/values/theme_common.xml
    +++ b/vector/src/main/res/values/theme_common.xml
    @@ -10,4 +10,15 @@
     
         
    +
     
    \ No newline at end of file