Merge pull request #1636 from vector-im/feature/attachement_pager

Feature/attachement pager
This commit is contained in:
Valere 2020-07-10 15:47:32 +02:00 committed by GitHub
commit 8582ad6015
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 2775 additions and 56 deletions

View file

@ -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)

1
attachment-viewer/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -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'
}

View file

21
attachment-viewer/proguard-rules.pro vendored Normal file
View file

@ -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

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="im.vector.riotx.attachmentviewer" />

View file

@ -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)
}

View file

@ -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()
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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<BaseViewHolder>() {
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()
}
}

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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()
}
}

View file

@ -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) }

View file

@ -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)
}
}

View file

@ -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<AttachmentEventListener>? = 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
}
}

View file

@ -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)
}

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rootContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".AttachmentViewerActivity">
<View
android:id="@+id/backgroundView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:alpha="1"
android:background="@android:color/black" />
<FrameLayout
android:id="@+id/dismissContainer"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/transitionImageContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:ignore="UselessParent"
tools:visibility="invisible">
<ImageView
android:id="@+id/transitionImageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:ignore="ContentDescription" />
</FrameLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/attachmentPager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:orientation="horizontal"
android:visibility="visible" />
</FrameLayout>
</FrameLayout>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<ImageView
android:id="@+id/imageView"
android:visibility="visible"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<ProgressBar
android:layout_centerInParent="true"
android:id="@+id/imageLoaderProgress"
style="?android:attr/progressBarStyle"
android:layout_width="40dp"
android:layout_height="40dp"
android:visibility="visible"
tools:visibility="gone" />
</RelativeLayout>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<com.github.chrisbanes.photoview.PhotoView
android:id="@+id/touchImageView"
android:visibility="visible"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<ProgressBar
android:layout_centerInParent="true"
android:id="@+id/imageLoaderProgress"
style="?android:attr/progressBarStyle"
android:layout_width="40dp"
android:layout_height="40dp"
android:visibility="visible"
tools:visibility="gone" />
</RelativeLayout>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<ImageView
android:id="@+id/videoThumbnailImage"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:scaleType="centerInside" />
<VideoView
android:id="@+id/videoView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerInParent="true" />
<ImageView
android:id="@+id/videoControlIcon"
android:layout_centerInParent="true"
android:visibility="gone"
tools:visibility="visible"
android:layout_width="44dp"
android:layout_height="44dp"
/>
<ProgressBar
android:layout_centerInParent="true"
android:id="@+id/videoLoaderProgress"
style="?android:attr/progressBarStyle"
android:layout_width="40dp"
android:layout_height="40dp"
android:visibility="invisible"
tools:visibility="visible" />
<TextView
android:id="@+id/videoMediaViewerErrorView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_margin="16dp"
android:textSize="16sp"
android:visibility="gone"
tools:text="Error"
tools:visibility="visible" />
</RelativeLayout>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/design_default_color_primary">
<TextView
android:id="@+id/testPage"
android:layout_centerInParent="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1"
android:textSize="80sp"
android:textStyle="bold" />
</RelativeLayout>

View file

@ -39,6 +39,8 @@ allprojects {
includeGroupByRegex "com\\.github\\.yalantis"
// JsonViewer
includeGroupByRegex 'com\\.github\\.BillCarsonFr'
// PhotoView
includeGroupByRegex 'com\\.github\\.chrisbanes'
}
}
maven {

View file

@ -39,4 +39,6 @@ interface TimelineService {
fun getTimeLineEvent(eventId: String): TimelineEvent?
fun getTimeLineEventLive(eventId: String): LiveData<Optional<TimelineEvent>>
fun getAttachmentMessages() : List<TimelineEvent>
}

View file

@ -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<Optional<TimelineEvent>> {
@ -88,4 +94,16 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv
events.firstOrNull().toOptional()
}
}
override fun getAttachmentMessages(): List<TimelineEvent> {
// TODO pretty bad query.. maybe we should denormalize clear type in base?
return doWithRealm(monarchy.realmConfiguration) { realm ->
realm.where<TimelineEventEntity>()
.equalTo(TimelineEventEntityFields.ROOM_ID, roomId)
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING)
.findAll()
?.mapNotNull { timelineEventMapper.map(it).takeIf { it.root.isImageMessage() || it.root.isVideoMessage() } }
?: emptyList()
}
}
}

View file

@ -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'

View file

@ -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'

View file

@ -85,6 +85,11 @@
</intent-filter>
</activity>
<activity android:name=".features.media.ImageMediaViewerActivity" />
<activity
android:name=".features.media.VectorAttachmentViewerActivity"
android:theme="@style/AppTheme.Transparent" />
<activity android:name=".features.media.BigImageViewerActivity" />
<activity
android:name=".features.rageshake.BugReportActivity"

View file

@ -389,6 +389,9 @@ SOFTWARE.
<li>
<b>BillCarsonFr/JsonViewer</b>
</li>
<li>
<b>Copyright (C) 2018 stfalcon.com</b>
</li>
</ul>
<pre>
Apache License

View file

@ -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

View file

@ -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<out String>, grantResults: IntArray) {
if (allGranted(grantResults)) {
when (requestCode) {
SAVE_ATTACHEMENT_REQUEST_CODE -> {
SAVE_ATTACHEMENT_REQUEST_CODE -> {
sharedActionViewModel.pendingAction?.let {
handleActions(it)
sharedActionViewModel.pendingAction = null

View file

@ -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,

View file

@ -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<ImageView>(R.id.overlayBackButton).setOnClickListener {
onBack?.invoke()
}
findViewById<ImageView>(R.id.overlayShareButton).setOnClickListener {
onShareCallback?.invoke()
}
findViewById<ImageView>(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
}
}
}
}
}

View file

@ -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<ImageView, Drawable>(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<in Drawable>?) {
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<ImageView, Drawable>(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<in Drawable>?) {
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<ImageView, Drawable>(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<in Drawable>?) {
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<File> {
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))
}

View file

@ -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<AttachmentData>,
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<File> {
override fun onSuccess(data: File) {
callback(data)
}
override fun onFailure(failure: Throwable) {
callback(null)
}
}
)
}
}

View file

@ -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<Drawable> {
override fun onLoadFailed(e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean): Boolean {
callback?.invoke(false)
return false
}
override fun onResourceReady(resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
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<Drawable> {
return if (data.elementToDecrypt != null) {
// Encrypted image

View file

@ -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)

View file

@ -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<TimelineEvent>,
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<MessageContent>() 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<MessageContent>()
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<File> {
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<TimelineEvent>, initialIndex: Int): RoomEventsAttachmentProvider {
return RoomEventsAttachmentProvider(attachments, initialIndex, imageContentRenderer, vectorDateFormatter, session.fileService())
}
fun createProvider(attachments: List<AttachmentData>, room: Room?, initialIndex: Int): DataAttachmentRoomProvider {
return DataAttachmentRoomProvider(attachments, room, initialIndex, imageContentRenderer, vectorDateFormatter, session.fileService())
}
}

View file

@ -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<AttachmentData>(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<Args>(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<AttachmentData>,
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()))
}
}
}
}

View file

@ -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,

View file

@ -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<Pair<View, String>>) -> Unit)?) {
val intent = ImageMediaViewerActivity.newIntent(activity, mediaData, ViewCompat.getTransitionName(view))
val pairs = ArrayList<Pair<View, String>>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
override fun openMediaViewer(activity: Activity,
roomId: String,
mediaData: AttachmentData,
view: View,
inMemory: List<AttachmentData>,
options: ((MutableList<Pair<View, String>>) -> Unit)?) {
VectorAttachmentViewerActivity.newIntent(activity,
mediaData,
roomId,
mediaData.eventId,
inMemory,
ViewCompat.getTransitionName(view)).let { intent ->
val pairs = ArrayList<Pair<View, String>>()
activity.window.decorView.findViewById<View>(android.R.id.statusBarBackground)?.let {
pairs.add(Pair(it, Window.STATUS_BAR_BACKGROUND_TRANSITION_NAME))
}
activity.window.decorView.findViewById<View>(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) {

View file

@ -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<Pair<View, String>>) -> Unit)?)
fun openVideoViewer(activity: Activity, mediaData: VideoContentRenderer.Data)
fun openMediaViewer(activity: Activity,
roomId: String,
mediaData: AttachmentData,
view: View,
inMemory: List<AttachmentData> = emptyList(),
options: ((MutableList<Pair<View, String>>) -> Unit)?)
}

View file

@ -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<Ava
setLightStatusBar()
}
}
if (currentAlerter?.shouldBeDisplayedIn?.invoke(activity) == false) {
if (currentAlerter?.shouldBeDisplayedIn?.invoke(activity) == false || activity !is VectorBaseActivity) {
return
}

View file

@ -20,23 +20,34 @@ import android.os.Bundle
import android.util.DisplayMetrics
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.util.Pair
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.GridLayoutManager
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.appbar.AppBarLayout
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.getFileUrl
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
import im.vector.riotx.R
import im.vector.riotx.core.extensions.cleanup
import im.vector.riotx.core.extensions.trackItemsVisibilityChange
import im.vector.riotx.core.platform.StateView
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.DimensionConverter
import im.vector.riotx.features.media.AttachmentData
import im.vector.riotx.features.media.ImageContentRenderer
import im.vector.riotx.features.media.VideoContentRenderer
import im.vector.riotx.features.roomprofile.uploads.RoomUploadsAction
import im.vector.riotx.features.roomprofile.uploads.RoomUploadsFragment
import im.vector.riotx.features.roomprofile.uploads.RoomUploadsViewModel
import im.vector.riotx.features.roomprofile.uploads.RoomUploadsViewState
import kotlinx.android.synthetic.main.fragment_generic_state_view_recycler.*
import kotlinx.android.synthetic.main.fragment_room_uploads.*
import javax.inject.Inject
class RoomUploadsMediaFragment @Inject constructor(
@ -76,12 +87,86 @@ class RoomUploadsMediaFragment @Inject constructor(
controller.listener = null
}
override fun onOpenImageClicked(view: View, mediaData: ImageContentRenderer.Data) {
navigator.openImageViewer(requireActivity(), mediaData, view, null)
// It's very strange i can't just access
// the app bar using find by id...
private fun trickFindAppBar(): AppBarLayout? {
return activity?.supportFragmentManager?.fragments
?.filterIsInstance<RoomUploadsFragment>()
?.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<AttachmentData> {
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() {

View file

@ -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<UploadsImageItem.Holder>() {
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() {

View file

@ -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<UploadsVideoItem.Holder>() {
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() {

View file

@ -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
)
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View file

@ -8,6 +8,8 @@
<com.google.android.material.appbar.AppBarLayout
style="@style/VectorAppBarLayoutStyle"
android:id="@+id/roomUploadsAppBar"
android:transitionName="toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="4dp">

View file

@ -0,0 +1,133 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<View
android:id="@+id/overlayTopBackground"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="@color/black_alpha"
app:layout_constraintTop_toTopOf="parent">
</View>
<ImageView
android:id="@+id/overlayBackButton"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/action_close"
android:focusable="true"
android:padding="6dp"
android:scaleType="centerInside"
android:tint="@color/white"
app:layout_constraintBottom_toBottomOf="@id/overlayTopBackground"
app:layout_constraintStart_toStartOf="@id/overlayTopBackground"
app:layout_constraintTop_toTopOf="@id/overlayTopBackground"
app:srcCompat="@drawable/ic_back_24dp" />
<TextView
android:id="@+id/overlayCounterText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@id/overlayInfoText"
app:layout_constraintEnd_toStartOf="@id/overlayShareButton"
app:layout_constraintStart_toEndOf="@id/overlayBackButton"
app:layout_constraintTop_toTopOf="@id/overlayTopBackground"
app:layout_constraintVertical_chainStyle="packed"
tools:text="1 of 200" />
<TextView
android:id="@+id/overlayInfoText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/overlayTopBackground"
app:layout_constraintEnd_toStartOf="@id/overlayShareButton"
app:layout_constraintStart_toStartOf="@id/overlayCounterText"
app:layout_constraintTop_toBottomOf="@id/overlayCounterText"
tools:text="Bill 29 Jun at 19:42" />
<ImageView
android:id="@+id/overlayShareButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/share"
android:focusable="true"
android:padding="6dp"
android:tint="@color/white"
app:layout_constraintBottom_toBottomOf="@id/overlayTopBackground"
app:layout_constraintEnd_toEndOf="@id/overlayTopBackground"
app:layout_constraintTop_toTopOf="@id/overlayTopBackground"
app:srcCompat="@drawable/ic_share" />
<androidx.constraintlayout.widget.Group
android:id="@+id/overlayVideoControlsGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="overlayBottomBackground,overlayBackButton,overlayPlayPauseButton,overlaySeekBar"
tools:visibility="visible" />
<View
android:id="@+id/overlayBottomBackground"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="@color/black_alpha"
app:layout_constraintBottom_toBottomOf="parent" />
<ImageView
android:id="@+id/overlayPlayPauseButton"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/play_video"
android:focusable="true"
android:padding="6dp"
android:scaleType="centerInside"
android:tint="@color/white"
app:layout_constraintBottom_toBottomOf="@id/overlayBottomBackground"
app:layout_constraintStart_toStartOf="@id/overlayBottomBackground"
app:layout_constraintTop_toTopOf="@id/overlayBottomBackground"
app:srcCompat="@drawable/ic_play_arrow" />
<SeekBar
android:id="@+id/overlaySeekBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:backgroundTint="@color/white"
android:progressBackgroundTint="@color/white"
android:thumbTint="@color/white"
app:layout_constraintBottom_toBottomOf="@id/overlayBottomBackground"
app:layout_constraintEnd_toEndOf="@id/overlayBottomBackground"
app:layout_constraintStart_toEndOf="@id/overlayPlayPauseButton"
app:layout_constraintTop_toTopOf="@id/overlayBottomBackground" />
</merge>

View file

@ -40,6 +40,7 @@
<!-- Other usefull color -->
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="black_alpha">#55000000</color>
<!-- Palette: format fo naming:
'riotx_<name in the palette snake case>_<theme>'

View file

@ -76,6 +76,10 @@
<string name="disconnect">Disconnect</string>
<string name="report_content">Report content</string>
<string name="active_call">Active call</string>
<string name="play_video">Play</string>
<string name="pause_video">Pause</string>
<!-- First param will be replace by the value of ongoing_conference_call_voice, and second one by the value of ongoing_conference_call_video -->
<string name="ongoing_conference_call">Ongoing conference call.\nJoin as %1$s or %2$s</string>
<string name="ongoing_conference_call_voice">Voice</string>

View file

@ -10,4 +10,15 @@
<style name="AppTheme.AttachmentsPreview" parent="AppTheme.Base.Black"/>
<style name="AppTheme.Transparent" parent="AppTheme.Base.Black">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<!-- <item name="android:windowIsFloating">true</item>-->
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources>