Merge branch 'feature/timeline_media' into develop

This commit is contained in:
ganfra 2019-01-25 12:36:01 +01:00
commit 4c7a6dfe33
43 changed files with 698 additions and 93 deletions

View File

@ -15,8 +15,9 @@ android {
compileSdkVersion 28 compileSdkVersion 28
defaultConfig { defaultConfig {
applicationId "im.vector.riotredesign" applicationId "im.vector.riotredesign"
minSdkVersion 21 minSdkVersion 16
targetSdkVersion 28 targetSdkVersion 28
multiDexEnabled true
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -42,11 +43,14 @@ dependencies {


implementation project(":matrix-sdk-android") implementation project(":matrix-sdk-android")
implementation project(":matrix-sdk-android-rx") implementation project(":matrix-sdk-android-rx")
implementation 'com.android.support:multidex:1.0.3'


implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"


implementation 'androidx.appcompat:appcompat:1.1.0-alpha01' implementation 'androidx.appcompat:appcompat:1.1.0-alpha01'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.core:core-ktx:1.0.1'

// Paging // Paging
implementation 'androidx.paging:paging-runtime:2.0.0' implementation 'androidx.paging:paging-runtime:2.0.0'



View File

@ -1,6 +1,8 @@
package im.vector.riotredesign package im.vector.riotredesign


import android.app.Application import android.app.Application
import android.content.Context
import androidx.multidex.MultiDex
import com.jakewharton.threetenabp.AndroidThreeTen import com.jakewharton.threetenabp.AndroidThreeTen
import im.vector.matrix.android.BuildConfig import im.vector.matrix.android.BuildConfig
import im.vector.riotredesign.core.di.AppModule import im.vector.riotredesign.core.di.AppModule
@ -8,6 +10,7 @@ import org.koin.log.EmptyLogger
import org.koin.standalone.StandAloneContext.startKoin import org.koin.standalone.StandAloneContext.startKoin
import timber.log.Timber import timber.log.Timber



class Riot : Application() { class Riot : Application() {


override fun onCreate() { override fun onCreate() {
@ -19,4 +22,9 @@ class Riot : Application() {
startKoin(listOf(AppModule(this).definition), logger = EmptyLogger()) startKoin(listOf(AppModule(this).definition), logger = EmptyLogger())
} }


override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
MultiDex.install(this)
}

} }

View File

@ -1,18 +1,16 @@
package im.vector.riotredesign.features.home package im.vector.riotredesign.features.home


import androidx.core.content.ContextCompat
import android.widget.ImageView import android.widget.ImageView
import androidx.core.content.ContextCompat
import com.amulyakhare.textdrawable.TextDrawable import com.amulyakhare.textdrawable.TextDrawable
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.room.model.RoomMember import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.firstCharAsString import im.vector.riotredesign.core.extensions.firstCharAsString
import im.vector.riotredesign.core.glide.GlideApp import im.vector.riotredesign.core.glide.GlideApp


private const val MEDIA_URL = "https://matrix.org/_matrix/media/v1/download/"
private const val MXC_PREFIX = "mxc://"

object AvatarRenderer { object AvatarRenderer {


fun render(roomMember: RoomMember, imageView: ImageView) { fun render(roomMember: RoomMember, imageView: ImageView) {
@ -27,7 +25,7 @@ object AvatarRenderer {
if (name.isNullOrEmpty()) { if (name.isNullOrEmpty()) {
return return
} }
val resolvedUrl = avatarUrl?.replace(MXC_PREFIX, MEDIA_URL) val resolvedUrl = Matrix.getInstance().currentSession.contentUrlResolver().resolveFullSize(avatarUrl)
val avatarColor = ContextCompat.getColor(imageView.context, R.color.pale_teal) val avatarColor = ContextCompat.getColor(imageView.context, R.color.pale_teal)
val fallbackDrawable = TextDrawable.builder().buildRound(name.firstCharAsString().toUpperCase(), avatarColor) val fallbackDrawable = TextDrawable.builder().buildRound(name.firstCharAsString().toUpperCase(), avatarColor)



View File

@ -10,6 +10,7 @@ import im.vector.riotredesign.features.home.room.detail.timeline.RoomTopicItemFa
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineDateFormatter import im.vector.riotredesign.features.home.room.detail.timeline.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineItemFactory import im.vector.riotredesign.features.home.room.detail.timeline.TimelineItemFactory
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import org.koin.dsl.module.module import org.koin.dsl.module.module


class HomeModule { class HomeModule {
@ -21,7 +22,7 @@ class HomeModule {
} }


single { single {
MessageItemFactory(get()) MessageItemFactory(get(), get())
} }


single { single {
@ -49,7 +50,11 @@ class HomeModule {
} }


factory { (roomId: String) -> factory { (roomId: String) ->
TimelineEventController(roomId, get(), get()) TimelineEventController(roomId, get(), get(), get())
}

single {
TimelineMediaSizeProvider()
} }


single { single {

View File

@ -0,0 +1,33 @@
package im.vector.riotredesign.features.home.room.detail.timeline

import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.LayoutRes
import im.vector.riotredesign.core.epoxy.KotlinModel
import im.vector.riotredesign.features.home.AvatarRenderer

abstract class AbsMessageItem(private val informationData: MessageInformationData,
@LayoutRes layoutRes: Int
) : KotlinModel(layoutRes) {

protected abstract val avatarImageView: ImageView
protected abstract val memberNameView: TextView
protected abstract val timeView: TextView

override fun bind() {
if (informationData.showInformation) {
avatarImageView.visibility = View.VISIBLE
memberNameView.visibility = View.VISIBLE
timeView.visibility = View.VISIBLE
timeView.text = informationData.time
memberNameView.text = informationData.memberName
AvatarRenderer.render(informationData.avatarUrl, informationData.memberName?.toString(), avatarImageView)
} else {
avatarImageView.visibility = View.GONE
memberNameView.visibility = View.GONE
timeView.visibility = View.GONE
}
}

}

View File

@ -0,0 +1,24 @@
package im.vector.riotredesign.features.home.room.detail.timeline

import android.widget.ImageView
import android.widget.TextView
import im.vector.riotredesign.R
import im.vector.riotredesign.features.media.MediaContentRenderer

class MessageImageItem(
private val mediaData: MediaContentRenderer.Data,
informationData: MessageInformationData
) : AbsMessageItem(informationData, R.layout.item_timeline_event_image_message) {

override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
override val memberNameView by bind<TextView>(R.id.messageMemberNameView)
override val timeView by bind<TextView>(R.id.messageTimeView)
private val imageView by bind<ImageView>(R.id.messageImageView)

override fun bind() {
super.bind()
MediaContentRenderer.render(mediaData, MediaContentRenderer.Mode.THUMBNAIL, imageView)
}


}

View File

@ -0,0 +1,8 @@
package im.vector.riotredesign.features.home.room.detail.timeline

data class MessageInformationData(
val time: CharSequence? = null,
val avatarUrl: String?,
val memberName: CharSequence? = null,
val showInformation: Boolean = true
)

View File

@ -1,40 +0,0 @@
package im.vector.riotredesign.features.home.room.detail.timeline

import android.view.View
import android.widget.ImageView
import android.widget.TextView
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.KotlinModel
import im.vector.riotredesign.features.home.AvatarRenderer

class MessageItem(
val message: CharSequence? = null,
val time: CharSequence? = null,
val avatarUrl: String?,
val memberName: CharSequence? = null,
val showInformation: Boolean = true
) : KotlinModel(R.layout.item_timeline_event_message) {

private val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
private val memberNameView by bind<TextView>(R.id.messageMemberNameView)
private val timeView by bind<TextView>(R.id.messageTimeView)
private val messageView by bind<TextView>(R.id.messageTextView)

override fun bind() {
messageView.text = message
MatrixLinkify.addLinkMovementMethod(messageView)
if (showInformation) {
avatarImageView.visibility = View.VISIBLE
memberNameView.visibility = View.VISIBLE
timeView.visibility = View.VISIBLE
timeView.text = time
memberNameView.text = memberName
AvatarRenderer.render(avatarUrl, memberName?.toString(), avatarImageView)
} else {
avatarImageView.visibility = View.GONE
memberNameView.visibility = View.GONE
timeView.visibility = View.GONE
}
}
}

View File

@ -7,39 +7,74 @@ import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.TimelineEvent import im.vector.matrix.android.api.session.events.model.TimelineEvent
import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.MessageContent 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.MessageTextContent
import im.vector.riotredesign.core.extensions.localDateTime import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.media.MediaContentRenderer


class MessageItemFactory(private val timelineDateFormatter: TimelineDateFormatter) { class MessageItemFactory(private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val timelineDateFormatter: TimelineDateFormatter) {


private val messagesDisplayedWithInformation = HashSet<String?>() private val messagesDisplayedWithInformation = HashSet<String?>()


fun create(event: TimelineEvent, fun create(event: TimelineEvent,
nextEvent: TimelineEvent?, nextEvent: TimelineEvent?,
callback: TimelineEventController.Callback? callback: TimelineEventController.Callback?
): MessageItem? { ): AbsMessageItem? {


val messageContent: MessageContent? = event.root.content.toModel()
val roomMember = event.roomMember val roomMember = event.roomMember
if (messageContent == null) {
return null
}
val nextRoomMember = nextEvent?.roomMember val nextRoomMember = nextEvent?.roomMember


val date = event.root.localDateTime() val date = event.root.localDateTime()
val nextDate = nextEvent?.root?.localDateTime() val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60)) val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60))
?: false ?: false


if (addDaySeparator if (addDaySeparator
|| nextRoomMember != roomMember || nextRoomMember != roomMember
|| nextEvent?.root?.type != EventType.MESSAGE || nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo) { || isNextMessageReceivedMoreThanOneHourAgo) {
messagesDisplayedWithInformation.add(event.root.eventId) messagesDisplayedWithInformation.add(event.root.eventId)
} }


val message = messageContent.body?.let { val messageContent: MessageContent = event.root.content.toModel() ?: return null
val showInformation = messagesDisplayedWithInformation.contains(event.root.eventId)
val time = timelineDateFormatter.formatMessageHour(date)
val avatarUrl = roomMember?.avatarUrl
val memberName = roomMember?.displayName ?: event.root.sender
val informationData = MessageInformationData(time, avatarUrl, memberName, showInformation)

return when (messageContent) {
is MessageTextContent -> buildTextMessageItem(messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(messageContent, informationData)
else -> null
}
}

private fun buildImageMessageItem(messageContent: MessageImageContent,
informationData: MessageInformationData): MessageImageItem? {

val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
val data = MediaContentRenderer.Data(
url = messageContent.url,
height = messageContent.info.height,
maxHeight = maxHeight,
width = messageContent.info.width,
maxWidth = maxWidth,
rotation = messageContent.info.rotation,
orientation = messageContent.info.orientation
)
return MessageImageItem(data, informationData)
}

private fun buildTextMessageItem(messageContent: MessageTextContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageTextItem? {

val message = messageContent.body.let {
val spannable = SpannableStringBuilder(it) val spannable = SpannableStringBuilder(it)
MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback { MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback {
override fun onUrlClicked(url: String) { override fun onUrlClicked(url: String) {
@ -49,13 +84,9 @@ class MessageItemFactory(private val timelineDateFormatter: TimelineDateFormatte
Linkify.addLinks(spannable, Linkify.ALL) Linkify.addLinks(spannable, Linkify.ALL)
spannable spannable
} }
val showInformation = messagesDisplayedWithInformation.contains(event.root.eventId) return MessageTextItem(
return MessageItem(
message = message, message = message,
avatarUrl = roomMember?.avatarUrl, informationData = informationData
showInformation = showInformation,
time = timelineDateFormatter.formatMessageHour(date),
memberName = roomMember?.displayName ?: event.root.sender
) )
} }



View File

@ -0,0 +1,23 @@
package im.vector.riotredesign.features.home.room.detail.timeline

import android.widget.ImageView
import android.widget.TextView
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.riotredesign.R

class MessageTextItem(
val message: CharSequence? = null,
informationData: MessageInformationData
) : AbsMessageItem(informationData, R.layout.item_timeline_event_text_message) {

override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
override val memberNameView by bind<TextView>(R.id.messageMemberNameView)
override val timeView by bind<TextView>(R.id.messageTimeView)
private val messageView by bind<TextView>(R.id.messageTextView)

override fun bind() {
super.bind()
messageView.text = message
MatrixLinkify.addLinkMovementMethod(messageView)
}
}

View File

@ -1,5 +1,6 @@
package im.vector.riotredesign.features.home.room.detail.timeline package im.vector.riotredesign.features.home.room.detail.timeline


import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyAsyncUtil import com.airbnb.epoxy.EpoxyAsyncUtil
import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.EpoxyModel
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
@ -7,11 +8,13 @@ import im.vector.matrix.android.api.session.events.model.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.TimelineData import im.vector.matrix.android.api.session.room.timeline.TimelineData
import im.vector.riotredesign.core.extensions.localDateTime import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.features.home.LoadingItemModel_ import im.vector.riotredesign.features.home.LoadingItemModel_
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.paging.PagedListEpoxyController import im.vector.riotredesign.features.home.room.detail.timeline.paging.PagedListEpoxyController


class TimelineEventController(private val roomId: String, class TimelineEventController(private val roomId: String,
private val dateFormatter: TimelineDateFormatter, private val dateFormatter: TimelineDateFormatter,
private val timelineItemFactory: TimelineItemFactory private val timelineItemFactory: TimelineItemFactory,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider
) : PagedListEpoxyController<TimelineEvent>( ) : PagedListEpoxyController<TimelineEvent>(
EpoxyAsyncUtil.getAsyncBackgroundHandler(), EpoxyAsyncUtil.getAsyncBackgroundHandler(),
EpoxyAsyncUtil.getAsyncBackgroundHandler() EpoxyAsyncUtil.getAsyncBackgroundHandler()
@ -36,6 +39,10 @@ class TimelineEventController(private val roomId: String,
} }
} }


override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
timelineMediaSizeProvider.recyclerView = recyclerView
}


override fun buildItemModels(currentPosition: Int, items: List<TimelineEvent?>): List<EpoxyModel<*>> { override fun buildItemModels(currentPosition: Int, items: List<TimelineEvent?>): List<EpoxyModel<*>> {
if (items.isNullOrEmpty()) { if (items.isNullOrEmpty()) {

View File

@ -0,0 +1,31 @@
package im.vector.riotredesign.features.home.room.detail.timeline.helper

import androidx.recyclerview.widget.RecyclerView

class TimelineMediaSizeProvider {

lateinit var recyclerView: RecyclerView
private var cachedSize: Pair<Int, Int>? = null

fun getMaxSize(): Pair<Int, Int> {
return cachedSize ?: computeMaxSize().also { cachedSize = it }
}

private fun computeMaxSize(): Pair<Int, Int> {
val width = recyclerView.width
val height = recyclerView.height
val maxImageWidth: Int
val maxImageHeight: Int
// landscape / portrait
if (width < height) {
maxImageWidth = Math.round(width * 0.7f)
maxImageHeight = Math.round(height * 0.5f)
} else {
maxImageWidth = Math.round(width * 0.5f)
maxImageHeight = Math.round(height * 0.7f)
}
return Pair(maxImageWidth, maxImageHeight)
}


}

View File

@ -1,8 +1,9 @@
package im.vector.riotredesign.features.home.room.list package im.vector.riotredesign.features.home.room.list


import androidx.core.content.ContextCompat
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.KotlinModel import im.vector.riotredesign.core.epoxy.KotlinModel


@ -21,8 +22,9 @@ data class RoomCategoryItem(


override fun bind() { override fun bind() {
val expandedArrowDrawableRes = if (isExpanded) R.drawable.ic_expand_more_white else R.drawable.ic_expand_less_white val expandedArrowDrawableRes = if (isExpanded) R.drawable.ic_expand_more_white else R.drawable.ic_expand_less_white
val expandedArrowDrawable = ContextCompat.getDrawable(rootView.context, expandedArrowDrawableRes) val expandedArrowDrawable = ContextCompat.getDrawable(rootView.context, expandedArrowDrawableRes)?.also {
expandedArrowDrawable?.setTint(tintColor) DrawableCompat.setTint(it, tintColor)
}
titleView.setCompoundDrawablesWithIntrinsicBounds(expandedArrowDrawable, null, null, null) titleView.setCompoundDrawablesWithIntrinsicBounds(expandedArrowDrawable, null, null, null)
titleView.text = title titleView.text = title
rootView.setOnClickListener { listener?.invoke() } rootView.setOnClickListener { listener?.invoke() }

View File

@ -0,0 +1,85 @@
package im.vector.riotredesign.features.media

import android.media.ExifInterface
import android.widget.ImageView
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.riotredesign.core.glide.GlideApp

object MediaContentRenderer {

data class Data(
val url: String?,
val height: Int,
val maxHeight: Int,
val width: Int,
val maxWidth: Int = width,
val orientation: Int,
val rotation: Int
)

enum class Mode {
FULL_SIZE,
THUMBNAIL
}

fun render(data: Data, mode: Mode, imageView: ImageView) {
val (width, height) = processSize(data, mode)
imageView.layoutParams.height = height
imageView.layoutParams.width = width

val contentUrlResolver = Matrix.getInstance().currentSession.contentUrlResolver()
val resolvedUrl = when (mode) {
Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url)
Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
}
?: return

GlideApp
.with(imageView)
.load(resolvedUrl)
.thumbnail(0.3f)
.into(imageView)
}

private fun processSize(data: Data, mode: Mode): Pair<Int, Int> {
val maxImageWidth = data.maxWidth
val maxImageHeight = data.maxHeight
val rotationAngle = data.rotation
val orientation = data.orientation
var width = data.width
var height = data.height
var finalHeight = -1
var finalWidth = -1

// if the image size is known
// compute the expected height
if (width > 0 && height > 0) {
// swap width and height if the image is side oriented
if (rotationAngle == 90 || rotationAngle == 270) {
val tmp = width
width = height
height = tmp
} else if (orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270) {
val tmp = width
width = height
height = tmp
}
if (mode == Mode.FULL_SIZE) {
finalHeight = height
finalWidth = width
} else {
finalHeight = Math.min(maxImageWidth * height / width, maxImageHeight)
finalWidth = finalHeight * width / height
}
}
// ensure that some values are properly initialized
if (finalHeight < 0) {
finalHeight = maxImageHeight
}
if (finalWidth < 0) {
finalWidth = maxImageWidth
}
return Pair(finalWidth, finalHeight)
}
}

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="wrap_content"
android:paddingLeft="16dp"
android:paddingRight="16dp">


<ImageView
android:id="@+id/messageAvatarImageView"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />

<TextView
android:id="@+id/messageMemberNameView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textSize="15sp"
app:layout_constraintBottom_toTopOf="@+id/toolbarSubtitleView"
app:layout_constraintEnd_toStartOf="@+id/messageTimeView"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/full_names" />


<TextView
android:id="@+id/messageTimeView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="@color/brown_grey"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintTop_toTopOf="@id/messageMemberNameView"
tools:text="@tools:sample/date/hhmm" />

<ImageView
android:id="@+id/messageImageView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="64dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageMemberNameView" />

</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -24,7 +24,7 @@ android {
testOptions.unitTests.includeAndroidResources = true testOptions.unitTests.includeAndroidResources = true


defaultConfig { defaultConfig {
minSdkVersion 21 minSdkVersion 16
targetSdkVersion 28 targetSdkVersion 28
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"

View File

@ -1,7 +1,7 @@
package im.vector.matrix.android.api package im.vector.matrix.android.api


import androidx.lifecycle.ProcessLifecycleOwner
import android.content.Context import android.content.Context
import androidx.lifecycle.ProcessLifecycleOwner
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.auth.Authenticator import im.vector.matrix.android.api.auth.Authenticator
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session

View File

@ -2,6 +2,7 @@ package im.vector.matrix.android.api.session


import androidx.annotation.MainThread import androidx.annotation.MainThread
import im.vector.matrix.android.api.auth.data.SessionParams import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.group.GroupService import im.vector.matrix.android.api.session.group.GroupService
import im.vector.matrix.android.api.session.room.RoomService import im.vector.matrix.android.api.session.room.RoomService


@ -15,6 +16,8 @@ interface Session : RoomService, GroupService {
@MainThread @MainThread
fun close() fun close()


fun contentUrlResolver(): ContentUrlResolver

fun addListener(listener: Listener) fun addListener(listener: Listener)


fun removeListener(listener: Listener) fun removeListener(listener: Listener)

View File

@ -0,0 +1,49 @@
/*
*
* * Copyright 2019 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.matrix.android.api.session.content

/**
* This interface defines methods for accessing content from the current session.
*/
interface ContentUrlResolver {

enum class ThumbnailMethod(val value: String) {
CROP("crop"),
SCALE("scale")
}

/**
* Get the actual URL for accessing the full-size image of a Matrix media content URI.
*
* @param contentUrl the Matrix media content URI (in the form of "mxc://...").
* @return the URL to access the described resource, or null if the url is invalid.
*/
fun resolveFullSize(contentUrl: String?): String?

/**
* Get the actual URL for accessing the thumbnail image of a given Matrix media content URI.
*
* @param contentUrl the Matrix media content URI (in the form of "mxc://...").
* @param width the desired width
* @param height the desired height
* @param method the desired method (METHOD_CROP or METHOD_SCALE)
* @return the URL to access the described resource, or null if the url is invalid.
*/
fun resolveThumbnail(contentUrl: String?, width: Int, height: Int, method: ThumbnailMethod): String?
}

View File

@ -0,0 +1,11 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class AudioInfo(
@Json(name = "mimetype") val mimeType: String,
@Json(name = "size") val size: Long,
@Json(name = "duration") val duration: Int
)

View File

@ -0,0 +1,12 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class FileInfo(
@Json(name = "mimetype") val mimeType: String,
@Json(name = "size") val size: Long,
@Json(name = "thumbnail_info") val thumbnailInfo: ThumbnailInfo? = null,
@Json(name = "thumbnail_url") val thumbnailUrl: String? = null
)

View File

@ -0,0 +1,17 @@
package im.vector.matrix.android.api.session.room.model.message

import android.media.ExifInterface
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class ImageInfo(
@Json(name = "mimetype") val mimeType: String,
@Json(name = "w") val width: Int = 0,
@Json(name = "h") val height: Int = 0,
@Json(name = "size") val size: Int = 0,
@Json(name = "rotation") val rotation: Int = 0,
@Json(name = "orientation") val orientation: Int = ExifInterface.ORIENTATION_NORMAL,
@Json(name = "thumbnail_info") val thumbnailInfo: ThumbnailInfo? = null,
@Json(name = "thumbnail_url") val thumbnailUrl: String? = null
)

View File

@ -0,0 +1,10 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class LocationInfo(
@Json(name = "thumbnail_url") val thumbnailUrl: String,
@Json(name = "thumbnail_info") val thumbnailInfo: ThumbnailInfo
)

View File

@ -0,0 +1,12 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class MessageAudioContent(
@Json(name = "msgtype") override val type: String,
@Json(name = "body") override val body: String,
@Json(name = "info") val info: AudioInfo,
@Json(name = "url") val url: String? = null
) : MessageContent

View File

@ -0,0 +1,6 @@
package im.vector.matrix.android.api.session.room.model.message

interface MessageContent {
val type: String
val body: String
}

View File

@ -0,0 +1,10 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class MessageDefaultContent(
@Json(name = "msgtype") override val type: String,
@Json(name = "body") override val body: String
) : MessageContent

View File

@ -0,0 +1,12 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class MessageEmoteContent(
@Json(name = "msgtype") override val type: String,
@Json(name = "body") override val body: String,
@Json(name = "format") val format: String? = null,
@Json(name = "formatted_body") val formattedBody: String? = null
) : MessageContent

View File

@ -0,0 +1,13 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class MessageFileContent(
@Json(name = "msgtype") override val type: String,
@Json(name = "body") override val body: String,
@Json(name = "filename") val filename: String? = null,
@Json(name = "info") val info: FileInfo,
@Json(name = "url") val url: String? = null
) : MessageContent

View File

@ -0,0 +1,12 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class MessageImageContent(
@Json(name = "msgtype") override val type: String,
@Json(name = "body") override val body: String,
@Json(name = "info") val info: ImageInfo,
@Json(name = "url") val url: String? = null
) : MessageContent

View File

@ -0,0 +1,12 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class MessageLocationContent(
@Json(name = "msgtype") override val type: String,
@Json(name = "body") override val body: String,
@Json(name = "geo_uri") val geoUri: String,
@Json(name = "info") val info: LocationInfo
) : MessageContent

View File

@ -0,0 +1,12 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class MessageNoticeContent(
@Json(name = "msgtype") override val type: String,
@Json(name = "body") override val body: String,
@Json(name = "format") val format: String? = null,
@Json(name = "formatted_body") val formattedBody: String? = null
) : MessageContent

View File

@ -1,14 +1,12 @@
package im.vector.matrix.android.api.session.room.model package im.vector.matrix.android.api.session.room.model.message


import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass


@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class MessageContent( data class MessageTextContent(

@Json(name = "msgtype") override val type: String,
@Json(name = "msgtype") val type: String? = null, @Json(name = "body") override val body: String,
@Json(name = "body") val body: String? = null,
@Json(name = "format") val format: String? = null, @Json(name = "format") val format: String? = null,
@Json(name = "formatted_body") val formattedBody: String? = null @Json(name = "formatted_body") val formattedBody: String? = null

) : MessageContent
)

View File

@ -1,4 +1,4 @@
package im.vector.matrix.android.api.session.room.model package im.vector.matrix.android.api.session.room.model.message


object MessageType { object MessageType {


View File

@ -0,0 +1,12 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class MessageVideoContent(
@Json(name = "msgtype") override val type: String,
@Json(name = "body") override val body: String,
@Json(name = "info") val info: VideoInfo,
@Json(name = "url") val url: String? = null
) : MessageContent

View File

@ -0,0 +1,12 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class ThumbnailInfo(
@Json(name = "w") val width: Int,
@Json(name = "h") val height: Int,
@Json(name = "size") val size: Long,
@Json(name = "mimetype") val mimeType: String
)

View File

@ -0,0 +1,15 @@
package im.vector.matrix.android.api.session.room.model.message

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class VideoInfo(
@Json(name = "mimetype") val mimeType: String,
@Json(name = "w") val w: Int,
@Json(name = "h") val h: Int,
@Json(name = "size") val size: Long,
@Json(name = "duration") val duration: Int,
@Json(name = "thumbnail_info") val thumbnailInfo: ThumbnailInfo? = null,
@Json(name = "thumbnail_url") val thumbnailUrl: String? = null
)

View File

@ -4,8 +4,8 @@ import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.events.model.Content import im.vector.matrix.android.api.session.events.model.Content
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.model.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.MessageType import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.internal.di.MoshiProvider import im.vector.matrix.android.internal.di.MoshiProvider


internal class EventFactory(private val credentials: Credentials) { internal class EventFactory(private val credentials: Credentials) {
@ -13,7 +13,7 @@ internal class EventFactory(private val credentials: Credentials) {
private val moshi = MoshiProvider.providesMoshi() private val moshi = MoshiProvider.providesMoshi()


fun createTextEvent(roomId: String, text: String): Event { fun createTextEvent(roomId: String, text: String): Event {
val content = MessageContent(type = MessageType.MSGTYPE_TEXT, body = text) val content = MessageTextContent(type = MessageType.MSGTYPE_TEXT, body = text)


return Event( return Event(
roomId = roomId, roomId = roomId,

View File

@ -1,6 +1,17 @@
package im.vector.matrix.android.internal.di package im.vector.matrix.android.internal.di


import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageDefaultContent
import im.vector.matrix.android.api.session.room.model.message.MessageEmoteContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageLocationContent
import im.vector.matrix.android.api.session.room.model.message.MessageNoticeContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.internal.network.parsing.RuntimeJsonAdapterFactory import im.vector.matrix.android.internal.network.parsing.RuntimeJsonAdapterFactory
import im.vector.matrix.android.internal.network.parsing.UriMoshiAdapter import im.vector.matrix.android.internal.network.parsing.UriMoshiAdapter
import im.vector.matrix.android.internal.session.sync.model.UserAccountData import im.vector.matrix.android.internal.session.sync.model.UserAccountData
@ -14,6 +25,16 @@ object MoshiProvider {
.add(RuntimeJsonAdapterFactory.of(UserAccountData::class.java, "type", UserAccountDataFallback::class.java) .add(RuntimeJsonAdapterFactory.of(UserAccountData::class.java, "type", UserAccountDataFallback::class.java)
.registerSubtype(UserAccountDataDirectMessages::class.java, UserAccountData.TYPE_DIRECT_MESSAGES) .registerSubtype(UserAccountDataDirectMessages::class.java, UserAccountData.TYPE_DIRECT_MESSAGES)
) )
.add(RuntimeJsonAdapterFactory.of(MessageContent::class.java, "msgtype", MessageDefaultContent::class.java)
.registerSubtype(MessageTextContent::class.java, MessageType.MSGTYPE_TEXT)
.registerSubtype(MessageNoticeContent::class.java, MessageType.MSGTYPE_NOTICE)
.registerSubtype(MessageEmoteContent::class.java, MessageType.MSGTYPE_EMOTE)
.registerSubtype(MessageAudioContent::class.java, MessageType.MSGTYPE_AUDIO)
.registerSubtype(MessageImageContent::class.java, MessageType.MSGTYPE_IMAGE)
.registerSubtype(MessageVideoContent::class.java, MessageType.MSGTYPE_VIDEO)
.registerSubtype(MessageLocationContent::class.java, MessageType.MSGTYPE_LOCATION)
.registerSubtype(MessageFileContent::class.java, MessageType.MSGTYPE_FILE)
)
.build() .build()


fun providesMoshi(): Moshi { fun providesMoshi(): Moshi {

View File

@ -129,16 +129,8 @@ public final class RuntimeJsonAdapterFactory<T> implements JsonAdapter.Factory {
Object jsonValue = reader.readJsonValue(); Object jsonValue = reader.readJsonValue();
Map<String, Object> jsonObject = (Map<String, Object>) jsonValue; Map<String, Object> jsonObject = (Map<String, Object>) jsonValue;
Object label = jsonObject.get(labelKey); Object label = jsonObject.get(labelKey);
if (label == null) {
throw new JsonDataException("Missing label for " + labelKey);
}
if (!(label instanceof String)) { if (!(label instanceof String)) {
throw new JsonDataException("Label for '" return null;
+ labelKey
+ "' must be a string but was "
+ label
+ ", a "
+ label.getClass());
} }
JsonAdapter<Object> adapter = labelToAdapter.get(label); JsonAdapter<Object> adapter = labelToAdapter.get(label);
if (adapter == null) { if (adapter == null) {

View File

@ -1,10 +1,11 @@
package im.vector.matrix.android.internal.session package im.vector.matrix.android.internal.session


import androidx.lifecycle.LiveData
import android.os.Looper import android.os.Looper
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.auth.data.SessionParams import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.group.Group import im.vector.matrix.android.api.session.group.Group
import im.vector.matrix.android.api.session.group.GroupService import im.vector.matrix.android.api.session.group.GroupService
import im.vector.matrix.android.api.session.group.model.GroupSummary import im.vector.matrix.android.api.session.group.model.GroupSummary
@ -35,6 +36,7 @@ internal class DefaultSession(override val sessionParams: SessionParams) : Sessi
private val roomService by inject<RoomService>() private val roomService by inject<RoomService>()
private val groupService by inject<GroupService>() private val groupService by inject<GroupService>()
private val syncThread by inject<SyncThread>() private val syncThread by inject<SyncThread>()
private val contentUrlResolver by inject<ContentUrlResolver>()
private var isOpen = false private var isOpen = false


@MainThread @MainThread
@ -63,6 +65,10 @@ internal class DefaultSession(override val sessionParams: SessionParams) : Sessi
isOpen = false isOpen = false
} }


override fun contentUrlResolver(): ContentUrlResolver {
return contentUrlResolver
}

override fun addListener(listener: Session.Listener) { override fun addListener(listener: Session.Listener) {
sessionListeners.addListener(listener) sessionListeners.addListener(listener)
} }

View File

@ -3,9 +3,11 @@ package im.vector.matrix.android.internal.session
import android.content.Context import android.content.Context
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.auth.data.SessionParams import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.group.GroupService import im.vector.matrix.android.api.session.group.GroupService
import im.vector.matrix.android.api.session.room.RoomService import im.vector.matrix.android.api.session.room.RoomService
import im.vector.matrix.android.internal.database.LiveEntityObserver import im.vector.matrix.android.internal.database.LiveEntityObserver
import im.vector.matrix.android.internal.session.content.DefaultContentUrlResolver
import im.vector.matrix.android.internal.session.group.DefaultGroupService import im.vector.matrix.android.internal.session.group.DefaultGroupService
import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater
import im.vector.matrix.android.internal.session.room.DefaultRoomService import im.vector.matrix.android.internal.session.room.DefaultRoomService
@ -78,6 +80,10 @@ internal class SessionModule(private val sessionParams: SessionParams) {
SessionListeners() SessionListeners()
} }


scope(DefaultSession.SCOPE) {
DefaultContentUrlResolver(sessionParams.homeServerConnectionConfig) as ContentUrlResolver
}

scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
val roomSummaryUpdater = RoomSummaryUpdater(get(), get(), get(), get(), sessionParams.credentials) val roomSummaryUpdater = RoomSummaryUpdater(get(), get(), get(), get(), sessionParams.credentials)
val groupSummaryUpdater = GroupSummaryUpdater(get()) val groupSummaryUpdater = GroupSummaryUpdater(get())

View File

@ -0,0 +1,69 @@
/*
*
* * Copyright 2019 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.matrix.android.internal.session.content

import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig
import im.vector.matrix.android.api.session.content.ContentUrlResolver


private const val MATRIX_CONTENT_URI_SCHEME = "mxc://"
private const val URI_PREFIX_CONTENT_API = "/_matrix/media/v1/"

internal class DefaultContentUrlResolver(private val homeServerConnectionConfig: HomeServerConnectionConfig) : ContentUrlResolver {

override fun resolveFullSize(contentUrl: String?): String? {
if (contentUrl?.isValidMatrixContentUrl() == true) {
val baseUrl = homeServerConnectionConfig.homeServerUri.toString()
val prefix = URI_PREFIX_CONTENT_API + "download/"
return resolve(baseUrl, contentUrl, prefix)
}
return null
}

override fun resolveThumbnail(contentUrl: String?, width: Int, height: Int, method: ContentUrlResolver.ThumbnailMethod): String? {
if (contentUrl?.isValidMatrixContentUrl() == true) {
val baseUrl = homeServerConnectionConfig.homeServerUri.toString()
val prefix = URI_PREFIX_CONTENT_API + "thumbnail/"
val params = "?width=$width&height=$height&method=${method.value}"
return resolve(baseUrl, contentUrl, prefix, params)
}
// do not allow non-mxc content URLs
return null
}

private fun resolve(baseUrl: String,
contentUrl: String,
prefix: String,
params: String? = null): String? {

var serverAndMediaId = contentUrl.removePrefix(MATRIX_CONTENT_URI_SCHEME)
val fragmentOffset = serverAndMediaId.indexOf("#")
var fragment = ""
if (fragmentOffset >= 0) {
fragment = serverAndMediaId.substring(fragmentOffset)
serverAndMediaId = serverAndMediaId.substring(0, fragmentOffset)
}
return baseUrl + prefix + serverAndMediaId + (params ?: "") + fragment
}

private fun String.isValidMatrixContentUrl(): Boolean {
return startsWith(MATRIX_CONTENT_URI_SCHEME)
}

}