Merge branch 'feature/media_attachment' into develop

This commit is contained in:
ganfra 2019-04-11 13:21:51 +02:00
commit dcac9aed55
75 changed files with 2447 additions and 189 deletions

View File

@ -1,6 +1,7 @@
apply plugin: 'com.android.library' apply plugin: 'com.android.library'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'realm-android' apply plugin: 'realm-android'
apply plugin: 'okreplay' apply plugin: 'okreplay'


@ -19,6 +20,10 @@ repositories {
jcenter() jcenter()
} }


androidExtensions {
experimental = true
}

android { android {
compileSdkVersion 28 compileSdkVersion 28
testOptions.unitTests.includeAndroidResources = true testOptions.unitTests.includeAndroidResources = true

View File

@ -18,6 +18,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.ContentUploadStateTracker
import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.crypto.CryptoService import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.group.GroupService import im.vector.matrix.android.api.session.group.GroupService
@ -58,6 +59,11 @@ interface Session :
*/ */
fun contentUrlResolver(): ContentUrlResolver fun contentUrlResolver(): ContentUrlResolver


/**
* Returns the ContentUploadProgressTracker associated with the session
*/
fun contentUploadProgressTracker(): ContentUploadStateTracker

/** /**
* Add a listener to the session. * Add a listener to the session.
* @param listener the listener to add. * @param listener the listener to add.

View File

@ -0,0 +1,42 @@
/*
* 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

import android.os.Parcelable
import kotlinx.android.parcel.Parcelize

@Parcelize
data class ContentAttachmentData(
val size: Long = 0,
val duration: Long? = 0,
val date: Long = 0,
val height: Long? = 0,
val width: Long? = 0,
val name: String? = null,
val path: String? = null,
val mimeType: String? = null,
val type: Type
) : Parcelable {

enum class Type {
FILE,
IMAGE,
AUDIO,
VIDEO
}

}

View File

@ -0,0 +1,37 @@
/*
* 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

interface ContentUploadStateTracker {

fun track(eventId: String, updateListener: UpdateListener)

fun untrack(eventId: String, updateListener: UpdateListener)

interface UpdateListener {
fun onUpdate(state: State)
}

sealed class State {
object Idle : State()
data class ProgressData(val current: Long, val total: Long) : State()
object Success : State()
object Failure : State()
}


}

View File

@ -22,8 +22,8 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class VideoInfo( data class VideoInfo(
@Json(name = "mimetype") val mimeType: String, @Json(name = "mimetype") val mimeType: String,
@Json(name = "w") val w: Int = 0, @Json(name = "w") val width: Int = 0,
@Json(name = "h") val h: Int = 0, @Json(name = "h") val height: Int = 0,
@Json(name = "size") val size: Long = 0, @Json(name = "size") val size: Long = 0,
@Json(name = "duration") val duration: Int = 0, @Json(name = "duration") val duration: Int = 0,
@Json(name = "thumbnail_info") val thumbnailInfo: ThumbnailInfo? = null, @Json(name = "thumbnail_info") val thumbnailInfo: ThumbnailInfo? = null,

View File

@ -16,11 +16,11 @@


package im.vector.matrix.android.api.session.room.send package im.vector.matrix.android.api.session.room.send


import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable



/** /**
* This interface defines methods to send events in a room. It's implemented at the room level. * This interface defines methods to send events in a room. It's implemented at the room level.
*/ */
@ -30,12 +30,23 @@ interface SendService {
* Method to send a text message asynchronously. * Method to send a text message asynchronously.
* @param text the text message to send * @param text the text message to send
* @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE * @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE
* @param callback the callback to be notified.
* @return a [Cancelable] * @return a [Cancelable]
*/ */
fun sendTextMessage(text: String, fun sendTextMessage(text: String, msgType: String = MessageType.MSGTYPE_TEXT): Cancelable
msgType: String = MessageType.MSGTYPE_TEXT,
callback: MatrixCallback<Event>): Cancelable /**
* Method to send a media asynchronously.
* @param attachment the media to send
* @return a [Cancelable]
*/
fun sendMedia(attachment: ContentAttachmentData): Cancelable

/**
* Method to send a list of media asynchronously.
* @param attachments the list of media to send
* @return a [Cancelable]
*/
fun sendMedias(attachments: List<ContentAttachmentData>): Cancelable




} }

View File

@ -0,0 +1,31 @@
/*
* 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.room.send

enum class SendState {
UNKNOWN,
UNSENT,
ENCRYPTING,
SENDING,
SENT,
SYNCED;

fun isSent(): Boolean {
return this == SENT || this == SYNCED
}

}

View File

@ -18,6 +18,7 @@ package im.vector.matrix.android.api.session.room.timeline


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.room.model.RoomMember import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.send.SendState


/** /**
* This data class is a wrapper around an Event. It allows to get useful data in the context of a timeline. * This data class is a wrapper around an Event. It allows to get useful data in the context of a timeline.
@ -28,7 +29,8 @@ data class TimelineEvent(
val root: Event, val root: Event,
val localId: String, val localId: String,
val displayIndex: Int, val displayIndex: Int,
val roomMember: RoomMember? val roomMember: RoomMember?,
val sendState: SendState
) { ) {


val metadata = HashMap<String, Any>() val metadata = HashMap<String, Any>()

View File

@ -0,0 +1,58 @@
/*
* 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.database

import android.os.Handler
import android.os.HandlerThread
import io.realm.Realm
import io.realm.RealmChangeListener
import io.realm.RealmConfiguration
import io.realm.RealmObject
import io.realm.RealmQuery
import io.realm.RealmResults
import java.util.concurrent.CountDownLatch

private const val THREAD_NAME = "REALM_QUERY_LATCH"

class RealmQueryLatch<E : RealmObject>(private val realmConfiguration: RealmConfiguration,
private val realmQueryBuilder: (Realm) -> RealmQuery<E>) {

fun await() {
val latch = CountDownLatch(1)
val handlerThread = HandlerThread(THREAD_NAME + hashCode())
handlerThread.start()
val handler = Handler(handlerThread.looper)
val runnable = Runnable {
val realm = Realm.getInstance(realmConfiguration)
val result = realmQueryBuilder(realm).findAllAsync()
result.addChangeListener(object : RealmChangeListener<RealmResults<E>> {
override fun onChange(t: RealmResults<E>) {
if (t.isNotEmpty()) {
result.removeChangeListener(this)
realm.close()
latch.countDown()
}
}
})
}
handler.post(runnable)
latch.await()
handlerThread.quit()
}


}

View File

@ -18,6 +18,7 @@ package im.vector.matrix.android.internal.database.helper


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.send.SendState
import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.mapper.toEntity import im.vector.matrix.android.internal.database.mapper.toEntity
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
@ -89,9 +90,10 @@ internal fun ChunkEntity.add(roomId: String,
isUnlinked: Boolean = false) { isUnlinked: Boolean = false) {


assertIsManaged() assertIsManaged()
if (event.eventId.isNullOrEmpty() || events.fastContains(event.eventId)) { if (event.eventId.isNullOrEmpty() || this.events.fastContains(event.eventId)) {
return return
} }

var currentDisplayIndex = lastDisplayIndex(direction, 0) var currentDisplayIndex = lastDisplayIndex(direction, 0)
if (direction == PaginationDirection.FORWARDS) { if (direction == PaginationDirection.FORWARDS) {
currentDisplayIndex += 1 currentDisplayIndex += 1
@ -115,6 +117,7 @@ internal fun ChunkEntity.add(roomId: String,
this.stateIndex = currentStateIndex this.stateIndex = currentStateIndex
this.isUnlinked = isUnlinked this.isUnlinked = isUnlinked
this.displayIndex = currentDisplayIndex this.displayIndex = currentDisplayIndex
this.sendState = SendState.SYNCED
} }
// We are not using the order of the list, but will be sorting with displayIndex field // We are not using the order of the list, but will be sorting with displayIndex field
events.add(eventEntity) events.add(eventEntity)
@ -122,14 +125,14 @@ internal fun ChunkEntity.add(roomId: String,


internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int { internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) { return when (direction) {
PaginationDirection.FORWARDS -> forwardsDisplayIndex PaginationDirection.FORWARDS -> forwardsDisplayIndex
PaginationDirection.BACKWARDS -> backwardsDisplayIndex PaginationDirection.BACKWARDS -> backwardsDisplayIndex
} ?: defaultValue } ?: defaultValue
} }


internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int { internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) { return when (direction) {
PaginationDirection.FORWARDS -> forwardsStateIndex PaginationDirection.FORWARDS -> forwardsStateIndex
PaginationDirection.BACKWARDS -> backwardsStateIndex PaginationDirection.BACKWARDS -> backwardsStateIndex
} ?: defaultValue } ?: defaultValue
} }

View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.internal.database.helper package im.vector.matrix.android.internal.database.helper


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.room.send.SendState
import im.vector.matrix.android.internal.database.mapper.toEntity import im.vector.matrix.android.internal.database.mapper.toEntity
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomEntity
@ -49,6 +50,16 @@ internal fun RoomEntity.addStateEvents(stateEvents: List<Event>,
this.stateIndex = stateIndex this.stateIndex = stateIndex
this.isUnlinked = isUnlinked this.isUnlinked = isUnlinked
} }
untimelinedStateEvents.add(eventEntity) untimelinedStateEvents.add(0, eventEntity)
} }
} }

internal fun RoomEntity.addSendingEvent(event: Event,
stateIndex: Int) {
assertIsManaged()
val eventEntity = event.toEntity(roomId).apply {
this.sendState = SendState.UNSENT
this.stateIndex = stateIndex
}
sendingTimelineEvents.add(0, eventEntity)
}

View File

@ -16,12 +16,15 @@


package im.vector.matrix.android.internal.database.model package im.vector.matrix.android.internal.database.model


import im.vector.matrix.android.api.session.room.send.SendState
import io.realm.RealmObject import io.realm.RealmObject
import io.realm.RealmResults import io.realm.RealmResults
import io.realm.annotations.Ignore
import io.realm.annotations.Index import io.realm.annotations.Index
import io.realm.annotations.LinkingObjects import io.realm.annotations.LinkingObjects
import io.realm.annotations.PrimaryKey import io.realm.annotations.PrimaryKey
import java.util.* import java.util.*
import kotlin.properties.Delegates


internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUID().toString(), internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUID().toString(),
@Index var eventId: String = "", @Index var eventId: String = "",
@ -45,6 +48,13 @@ internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUI
BOTH BOTH
} }


private var sendStateStr: String = SendState.UNKNOWN.name

@delegate:Ignore
var sendState: SendState by Delegates.observable(SendState.valueOf(sendStateStr)) { _, _, newValue ->
sendStateStr = newValue.name
}

companion object companion object


@LinkingObjects("events") @LinkingObjects("events")

View File

@ -26,6 +26,7 @@ import kotlin.properties.Delegates
internal open class RoomEntity(@PrimaryKey var roomId: String = "", internal open class RoomEntity(@PrimaryKey var roomId: String = "",
var chunks: RealmList<ChunkEntity> = RealmList(), var chunks: RealmList<ChunkEntity> = RealmList(),
var untimelinedStateEvents: RealmList<EventEntity> = RealmList(), var untimelinedStateEvents: RealmList<EventEntity> = RealmList(),
var sendingTimelineEvents: RealmList<EventEntity> = RealmList(),
var areAllMembersLoaded: Boolean = false var areAllMembersLoaded: Boolean = false
) : RealmObject() { ) : RealmObject() {



View File

@ -0,0 +1,70 @@
/*
* 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.network

import okhttp3.MediaType
import okhttp3.RequestBody
import okio.Buffer
import okio.BufferedSink
import okio.ForwardingSink
import okio.Okio
import okio.Sink
import java.io.IOException

internal class ProgressRequestBody(private val delegate: RequestBody,
private val listener: Listener) : RequestBody() {

private lateinit var countingSink: CountingSink

override fun contentType(): MediaType? {
return delegate.contentType()
}

override fun contentLength(): Long {
try {
return delegate.contentLength()
} catch (e: IOException) {
e.printStackTrace()
}

return -1
}

@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
countingSink = CountingSink(sink)
val bufferedSink = Okio.buffer(countingSink)
delegate.writeTo(bufferedSink)
bufferedSink.flush()
}

private inner class CountingSink(delegate: Sink) : ForwardingSink(delegate) {

private var bytesWritten: Long = 0

@Throws(IOException::class)
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
bytesWritten += byteCount
listener.onProgress(bytesWritten, contentLength())
}
}

interface Listener {
fun onProgress(current: Long, total: Long)
}
}

View File

@ -23,6 +23,7 @@ import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
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.ContentUploadStateTracker
import im.vector.matrix.android.api.session.content.ContentUrlResolver 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
@ -37,6 +38,7 @@ import im.vector.matrix.android.api.session.user.model.User
import im.vector.matrix.android.internal.database.LiveEntityObserver import im.vector.matrix.android.internal.database.LiveEntityObserver
import im.vector.matrix.android.internal.di.MatrixKoinComponent import im.vector.matrix.android.internal.di.MatrixKoinComponent
import im.vector.matrix.android.internal.di.MatrixKoinHolder import im.vector.matrix.android.internal.di.MatrixKoinHolder
import im.vector.matrix.android.internal.session.content.ContentModule
import im.vector.matrix.android.internal.session.group.GroupModule import im.vector.matrix.android.internal.session.group.GroupModule
import im.vector.matrix.android.internal.session.room.RoomModule import im.vector.matrix.android.internal.session.room.RoomModule
import im.vector.matrix.android.internal.session.signout.SignOutModule import im.vector.matrix.android.internal.session.signout.SignOutModule
@ -64,6 +66,7 @@ internal class DefaultSession(override val sessionParams: SessionParams) : Sessi
private val signOutService by inject<SignOutService>() private val signOutService by inject<SignOutService>()
private val syncThread by inject<SyncThread>() private val syncThread by inject<SyncThread>()
private val contentUrlResolver by inject<ContentUrlResolver>() private val contentUrlResolver by inject<ContentUrlResolver>()
private val contentUploadProgressTracker by inject<ContentUploadStateTracker>()
private var isOpen = false private var isOpen = false


@MainThread @MainThread
@ -77,7 +80,8 @@ internal class DefaultSession(override val sessionParams: SessionParams) : Sessi
val groupModule = GroupModule().definition val groupModule = GroupModule().definition
val signOutModule = SignOutModule().definition val signOutModule = SignOutModule().definition
val userModule = UserModule().definition val userModule = UserModule().definition
MatrixKoinHolder.instance.loadModules(listOf(sessionModule, syncModule, roomModule, groupModule, signOutModule, userModule)) val contentModule = ContentModule().definition
MatrixKoinHolder.instance.loadModules(listOf(sessionModule, syncModule, roomModule, groupModule, userModule, signOutModule, contentModule))
scope = getKoin().getOrCreateScope(SCOPE) scope = getKoin().getOrCreateScope(SCOPE)
if (!monarchy.isMonarchyThreadOpen) { if (!monarchy.isMonarchyThreadOpen) {
monarchy.openManually() monarchy.openManually()
@ -121,6 +125,10 @@ internal class DefaultSession(override val sessionParams: SessionParams) : Sessi
return contentUrlResolver return contentUrlResolver
} }


override fun contentUploadProgressTracker(): ContentUploadStateTracker {
return contentUploadProgressTracker
}

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

View File

@ -19,13 +19,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.api.session.signout.SignOutService import im.vector.matrix.android.api.session.signout.SignOutService
import im.vector.matrix.android.api.session.user.UserService import im.vector.matrix.android.api.session.user.UserService
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
@ -116,10 +114,6 @@ internal class SessionModule(private val sessionParams: SessionParams) {
SessionListeners() SessionListeners()
} }


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

scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
val groupSummaryUpdater = GroupSummaryUpdater(get()) val groupSummaryUpdater = GroupSummaryUpdater(get())
val eventsPruner = EventsPruner(get()) val eventsPruner = EventsPruner(get())

View File

@ -0,0 +1,45 @@
/*
* 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.SessionParams
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.internal.session.DefaultSession
import org.koin.dsl.module.module

internal class ContentModule {

val definition = module(override = true) {

scope(DefaultSession.SCOPE) {
DefaultContentUploadStateTracker() as ContentUploadStateTracker
}

scope(DefaultSession.SCOPE) {
ContentUploader(get(), get(), get<ContentUploadStateTracker>() as DefaultContentUploadStateTracker)
}

scope(DefaultSession.SCOPE) {
val sessionParams = get<SessionParams>()
DefaultContentUrlResolver(sessionParams.homeServerConnectionConfig) as ContentUrlResolver
}

}


}

View File

@ -0,0 +1,25 @@
/*
* 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 com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class ContentUploadResponse(
@Json(name = "content_uri") val contentUri: String
)

View File

@ -0,0 +1,90 @@
/*
* 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 arrow.core.Try
import arrow.core.Try.Companion.raise
import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.internal.di.MoshiProvider
import im.vector.matrix.android.internal.network.ProgressRequestBody
import okhttp3.HttpUrl
import okhttp3.MediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import java.io.File
import java.io.IOException


internal class ContentUploader(private val okHttpClient: OkHttpClient,
private val sessionParams: SessionParams,
private val contentUploadProgressTracker: DefaultContentUploadStateTracker) {

private val moshi = MoshiProvider.providesMoshi()
private val responseAdapter = moshi.adapter(ContentUploadResponse::class.java)

fun uploadFile(eventId: String, attachment: ContentAttachmentData): Try<ContentUploadResponse> {
if (attachment.path == null || attachment.mimeType == null) {
return raise(RuntimeException())
}
val file = File(attachment.path)
val urlString = sessionParams.homeServerConnectionConfig.homeServerUri.toString() + URI_PREFIX_CONTENT_API + "upload"

val urlBuilder = HttpUrl.parse(urlString)?.newBuilder()
?: return raise(RuntimeException())

val httpUrl = urlBuilder
.addQueryParameter(
"filename", attachment.name
).build()

val requestBody = RequestBody.create(
MediaType.parse(attachment.mimeType),
file
)
val progressRequestBody = ProgressRequestBody(requestBody, object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
contentUploadProgressTracker.setProgress(eventId, current, total)
}
})

val request = Request.Builder()
.url(httpUrl)
.post(progressRequestBody)
.build()

val result = Try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException()
} else {
response.body()?.source()?.let {
responseAdapter.fromJson(it)
}
?: throw IOException()
}
}
}
if (result.isFailure()) {
contentUploadProgressTracker.setFailure(eventId)
} else {
contentUploadProgressTracker.setSuccess(eventId)
}
return result
}
}

View File

@ -0,0 +1,67 @@
/*
* 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 android.os.Handler
import android.os.Looper
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker

internal class DefaultContentUploadStateTracker : ContentUploadStateTracker {

private val mainHandler = Handler(Looper.getMainLooper())
private val progressByEvent = mutableMapOf<String, ContentUploadStateTracker.State>()
private val listenersByEvent = mutableMapOf<String, MutableList<ContentUploadStateTracker.UpdateListener>>()

override fun track(eventId: String, updateListener: ContentUploadStateTracker.UpdateListener) {
val listeners = listenersByEvent[eventId] ?: ArrayList()
listeners.add(updateListener)
listenersByEvent[eventId] = listeners
val currentState = progressByEvent[eventId] ?: ContentUploadStateTracker.State.Idle
mainHandler.post { updateListener.onUpdate(currentState) }
}

override fun untrack(eventId: String, updateListener: ContentUploadStateTracker.UpdateListener) {
listenersByEvent[eventId]?.apply {
remove(updateListener)
}
}

internal fun setFailure(eventId: String) {
val failure = ContentUploadStateTracker.State.Failure
updateState(eventId, failure)
}

internal fun setSuccess(eventId: String) {
val success = ContentUploadStateTracker.State.Success
updateState(eventId, success)
}

internal fun setProgress(eventId: String, current: Long, total: Long) {
val progressData = ContentUploadStateTracker.State.ProgressData(current, total)
updateState(eventId, progressData)
}

private fun updateState(eventId: String, state: ContentUploadStateTracker.State) {
progressByEvent[eventId] = state
mainHandler.post {
listenersByEvent[eventId]?.also { listeners ->
listeners.forEach { it.onUpdate(state) }
}
}
}

}

View File

@ -21,7 +21,7 @@ import im.vector.matrix.android.api.session.content.ContentUrlResolver




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


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



View File

@ -0,0 +1,83 @@
/*
* 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 android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.toContent
import im.vector.matrix.android.api.session.events.model.toModel
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.internal.di.MatrixKoinComponent
import im.vector.matrix.android.internal.session.room.send.SendEventWorker
import im.vector.matrix.android.internal.util.WorkerParamsFactory
import org.koin.standalone.inject

internal class UploadContentWorker(context: Context, params: WorkerParameters)
: CoroutineWorker(context, params), MatrixKoinComponent {

private val mediaUploader by inject<ContentUploader>()

@JsonClass(generateAdapter = true)
internal data class Params(
val roomId: String,
val event: Event,
val attachment: ContentAttachmentData
)

override suspend fun doWork(): Result {
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.failure()

if (params.event.eventId == null) {
return Result.failure()
}
return mediaUploader
.uploadFile(params.event.eventId, params.attachment)
.fold({ handleFailure() }, { handleSuccess(params, it) })
}

private fun handleFailure(): Result {
return Result.retry()
}

private fun handleSuccess(params: Params, contentUploadResponse: ContentUploadResponse): Result {
val event = updateEvent(params.event, contentUploadResponse.contentUri)
val sendParams = SendEventWorker.Params(params.roomId, event)
return Result.success(WorkerParamsFactory.toData(sendParams))
}

private fun updateEvent(event: Event, url: String): Event {
val messageContent: MessageContent = event.content.toModel() ?: return event
val updatedContent = when (messageContent) {
is MessageImageContent -> messageContent.update(url)
else -> messageContent
}
return event.copy(content = updatedContent.toContent())
}

private fun MessageImageContent.update(url: String): MessageImageContent {
return copy(url = url)
}


}

View File

@ -23,7 +23,6 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.internal.database.RealmLiveEntityObserver import im.vector.matrix.android.internal.database.RealmLiveEntityObserver
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.GroupEntity import im.vector.matrix.android.internal.database.model.GroupEntity
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.util.WorkerParamsFactory import im.vector.matrix.android.internal.util.WorkerParamsFactory

View File

@ -25,7 +25,7 @@ import im.vector.matrix.android.internal.session.room.members.RoomMemberExtracto
import im.vector.matrix.android.internal.session.room.read.DefaultReadService import im.vector.matrix.android.internal.session.room.read.DefaultReadService
import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask
import im.vector.matrix.android.internal.session.room.send.DefaultSendService import im.vector.matrix.android.internal.session.room.send.DefaultSendService
import im.vector.matrix.android.internal.session.room.send.EventFactory import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
import im.vector.matrix.android.internal.session.room.state.DefaultStateService import im.vector.matrix.android.internal.session.room.state.DefaultStateService
import im.vector.matrix.android.internal.session.room.state.SendStateTask import im.vector.matrix.android.internal.session.room.state.SendStateTask
import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineService import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineService
@ -41,7 +41,7 @@ internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask,
private val paginationTask: PaginationTask, private val paginationTask: PaginationTask,
private val contextOfEventTask: GetContextOfEventTask, private val contextOfEventTask: GetContextOfEventTask,
private val setReadMarkersTask: SetReadMarkersTask, private val setReadMarkersTask: SetReadMarkersTask,
private val eventFactory: EventFactory, private val eventFactory: LocalEchoEventFactory,
private val taskExecutor: TaskExecutor) { private val taskExecutor: TaskExecutor) {


fun instantiate(roomId: String): Room { fun instantiate(roomId: String): Room {

View File

@ -25,10 +25,14 @@ import im.vector.matrix.android.internal.session.room.members.DefaultLoadRoomMem
import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTask import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask
import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask
import im.vector.matrix.android.internal.session.room.send.EventFactory import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
import im.vector.matrix.android.internal.session.room.state.DefaultSendStateTask import im.vector.matrix.android.internal.session.room.state.DefaultSendStateTask
import im.vector.matrix.android.internal.session.room.state.SendStateTask import im.vector.matrix.android.internal.session.room.state.SendStateTask
import im.vector.matrix.android.internal.session.room.timeline.* import im.vector.matrix.android.internal.session.room.timeline.DefaultGetContextOfEventTask
import im.vector.matrix.android.internal.session.room.timeline.DefaultPaginationTask
import im.vector.matrix.android.internal.session.room.timeline.GetContextOfEventTask
import im.vector.matrix.android.internal.session.room.timeline.PaginationTask
import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor
import org.koin.dsl.module.module import org.koin.dsl.module.module
import retrofit2.Retrofit import retrofit2.Retrofit


@ -63,7 +67,7 @@ class RoomModule {
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
EventFactory(get()) LocalEchoEventFactory(get())
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {

View File

@ -35,7 +35,9 @@ internal class EventsPruner(monarchy: Monarchy) :
override val query = Monarchy.Query<EventEntity> { EventEntity.where(it, type = EventType.REDACTION) } override val query = Monarchy.Query<EventEntity> { EventEntity.where(it, type = EventType.REDACTION) }


override fun processChanges(inserted: List<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) { override fun processChanges(inserted: List<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) {
val redactionEvents = inserted.map { it.asDomain() } val redactionEvents = inserted
.mapNotNull { it.asDomain().redacts }

val pruneEventWorkerParams = PruneEventWorker.Params(redactionEvents) val pruneEventWorkerParams = PruneEventWorker.Params(redactionEvents)
val workData = WorkerParamsFactory.toData(pruneEventWorkerParams) val workData = WorkerParamsFactory.toData(pruneEventWorkerParams)



View File

@ -21,7 +21,6 @@ import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
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.internal.database.mapper.ContentMapper import im.vector.matrix.android.internal.database.mapper.ContentMapper
import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.EventEntity
@ -37,8 +36,8 @@ internal class PruneEventWorker(context: Context,
) : Worker(context, workerParameters), MatrixKoinComponent { ) : Worker(context, workerParameters), MatrixKoinComponent {


@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
internal data class Params( internal class Params(
val redactionEvents: List<Event> val eventIdsToRedact: List<String>
) )


private val monarchy by inject<Monarchy>() private val monarchy by inject<Monarchy>()
@ -48,18 +47,19 @@ internal class PruneEventWorker(context: Context,
?: return Result.failure() ?: return Result.failure()


val result = monarchy.tryTransactionSync { realm -> val result = monarchy.tryTransactionSync { realm ->
params.redactionEvents.forEach { event -> params.eventIdsToRedact.forEach { eventId ->
pruneEvent(realm, event) pruneEvent(realm, eventId)
} }
} }
return result.fold({ Result.retry() }, { Result.success() }) return result.fold({ Result.retry() }, { Result.success() })
} }


private fun pruneEvent(realm: Realm, redactionEvent: Event?) { private fun pruneEvent(realm: Realm, eventIdToRedact: String) {
if (redactionEvent == null || redactionEvent.redacts.isNullOrEmpty()) { if (eventIdToRedact.isEmpty()) {
return return
} }
val eventToPrune = EventEntity.where(realm, eventId = redactionEvent.redacts).findFirst()
val eventToPrune = EventEntity.where(realm, eventId = eventIdToRedact).findFirst()
?: return ?: return


val allowedKeys = computeAllowedKeys(eventToPrune.type) val allowedKeys = computeAllowedKeys(eventToPrune.type)
@ -87,7 +87,7 @@ internal class PruneEventWorker(context: Context,
EventType.STATE_ROOM_ALIASES -> listOf("aliases") EventType.STATE_ROOM_ALIASES -> listOf("aliases")
EventType.STATE_CANONICAL_ALIAS -> listOf("alias") EventType.STATE_CANONICAL_ALIAS -> listOf("alias")
EventType.FEEDBACK -> listOf("type", "target_event_id") EventType.FEEDBACK -> listOf("type", "target_event_id")
else -> emptyList() else -> emptyList()
} }
} }



View File

@ -16,56 +16,115 @@


package im.vector.matrix.android.internal.session.room.send package im.vector.matrix.android.internal.session.room.send


import androidx.work.* import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.content.ContentAttachmentData
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.room.send.SendService import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.database.helper.add import im.vector.matrix.android.api.util.CancelableBag
import im.vector.matrix.android.api.util.addTo
import im.vector.matrix.android.internal.database.helper.addSendingEvent
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.content.UploadContentWorker
import im.vector.matrix.android.internal.util.CancelableWork import im.vector.matrix.android.internal.util.CancelableWork
import im.vector.matrix.android.internal.util.WorkerParamsFactory import im.vector.matrix.android.internal.util.WorkerParamsFactory
import im.vector.matrix.android.internal.util.tryTransactionAsync import im.vector.matrix.android.internal.util.tryTransactionAsync
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit


private const val SEND_WORK = "SEND_WORK" private const val SEND_WORK = "SEND_WORK"
private const val UPLOAD_WORK = "UPLOAD_WORK"
private const val BACKOFF_DELAY = 10_000L

private val WORK_CONSTRAINTS = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()


internal class DefaultSendService(private val roomId: String, internal class DefaultSendService(private val roomId: String,
private val eventFactory: EventFactory, private val eventFactory: LocalEchoEventFactory,
private val monarchy: Monarchy) : SendService { private val monarchy: Monarchy)
: SendService {


private val sendConstraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()


// TODO callback is not used override fun sendTextMessage(text: String, msgType: String): Cancelable {
override fun sendTextMessage(text: String, msgType: String, callback: MatrixCallback<Event>): Cancelable { val event = eventFactory.createTextEvent(roomId, msgType, text).also {
val event = eventFactory.createTextEvent(roomId, msgType, text) saveLocalEcho(it)

monarchy.tryTransactionAsync { realm ->
val chunkEntity = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId)
?: return@tryTransactionAsync
chunkEntity.add(roomId, event, PaginationDirection.FORWARDS)
} }
val sendWork = createSendEventWork(event)
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(SEND_WORK), ExistingWorkPolicy.APPEND, sendWork)
.enqueue()
return CancelableWork(sendWork.id)
}


val sendContentWorkerParams = SendEventWorker.Params(roomId, event) override fun sendMedias(attachments: List<ContentAttachmentData>): Cancelable {
val workData = WorkerParamsFactory.toData(sendContentWorkerParams) val cancelableBag = CancelableBag()
attachments.forEach {
sendMedia(it).addTo(cancelableBag)
}
return cancelableBag
}


val sendWork = OneTimeWorkRequestBuilder<SendEventWorker>() override fun sendMedia(attachment: ContentAttachmentData): Cancelable {
.setConstraints(sendConstraints) // Create an event with the media file path
.setInputData(workData) val event = eventFactory.createMediaEvent(roomId, attachment).also {
.setBackoffCriteria(BackoffPolicy.LINEAR, 10_000, TimeUnit.MILLISECONDS) saveLocalEcho(it)
.build() }
val uploadWork = createUploadMediaWork(event, attachment)
val sendWork = createSendEventWork(event)


WorkManager.getInstance() WorkManager.getInstance()
.beginUniqueWork(SEND_WORK, ExistingWorkPolicy.APPEND, sendWork) .beginUniqueWork(buildWorkIdentifier(UPLOAD_WORK), ExistingWorkPolicy.APPEND, uploadWork)
.then(sendWork)
.enqueue() .enqueue()


return CancelableWork(sendWork.id) return CancelableWork(sendWork.id)
}


private fun saveLocalEcho(event: Event) {
monarchy.tryTransactionAsync { realm ->
val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()
?: return@tryTransactionAsync
val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId = roomId)
?: return@tryTransactionAsync

roomEntity.addSendingEvent(event, liveChunk.forwardsStateIndex ?: 0)
}
}

private fun buildWorkIdentifier(identifier: String): String {
return "${roomId}_$identifier"
}

private fun createSendEventWork(event: Event): OneTimeWorkRequest {
val sendContentWorkerParams = SendEventWorker.Params(roomId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)

return OneTimeWorkRequestBuilder<SendEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(sendWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}

private fun createUploadMediaWork(event: Event, attachment: ContentAttachmentData): OneTimeWorkRequest {
val uploadMediaWorkerParams = UploadContentWorker.Params(roomId, event, attachment)
val uploadWorkData = WorkerParamsFactory.toData(uploadMediaWorkerParams)

return OneTimeWorkRequestBuilder<UploadContentWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(uploadWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
} }


} }

View File

@ -0,0 +1,126 @@
/*
* 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.room.send

import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.content.ContentAttachmentData
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.toContent
import im.vector.matrix.android.api.session.room.model.message.AudioInfo
import im.vector.matrix.android.api.session.room.model.message.FileInfo
import im.vector.matrix.android.api.session.room.model.message.ImageInfo
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
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.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.api.session.room.model.message.VideoInfo

internal class LocalEchoEventFactory(private val credentials: Credentials) {

fun createTextEvent(roomId: String, msgType: String, text: String): Event {
val content = MessageTextContent(type = msgType, body = text)
return createEvent(roomId, content)
}

fun createMediaEvent(roomId: String, attachment: ContentAttachmentData): Event {
return when (attachment.type) {
ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment)
ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment)
ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment)
ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment)
}
}

private fun createImageEvent(roomId: String, attachment: ContentAttachmentData): Event {
val content = MessageImageContent(
type = MessageType.MSGTYPE_IMAGE,
body = attachment.name ?: "image",
info = ImageInfo(
mimeType = attachment.mimeType ?: "image/png",
width = attachment.width?.toInt() ?: 0,
height = attachment.height?.toInt() ?: 0,
size = attachment.size.toInt()
),
url = attachment.path
)
return createEvent(roomId, content)
}

private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData): Event {
val content = MessageVideoContent(
type = MessageType.MSGTYPE_VIDEO,
body = attachment.name ?: "video",
info = VideoInfo(
mimeType = attachment.mimeType ?: "video/mpeg",
width = attachment.width?.toInt() ?: 0,
height = attachment.height?.toInt() ?: 0,
size = attachment.size,
duration = attachment.duration?.toInt() ?: 0
),
url = attachment.path
)
return createEvent(roomId, content)
}

private fun createAudioEvent(roomId: String, attachment: ContentAttachmentData): Event {
val content = MessageAudioContent(
type = MessageType.MSGTYPE_AUDIO,
body = attachment.name ?: "audio",
info = AudioInfo(
mimeType = attachment.mimeType ?: "audio/mpeg",
size = attachment.size
),
url = attachment.path
)
return createEvent(roomId, content)
}

private fun createFileEvent(roomId: String, attachment: ContentAttachmentData): Event {
val content = MessageFileContent(
type = MessageType.MSGTYPE_FILE,
body = attachment.name ?: "file",
info = FileInfo(
mimeType = attachment.mimeType ?: "application/octet-stream",
size = attachment.size
),
url = attachment.path
)
return createEvent(roomId, content)
}

private fun createEvent(roomId: String, content: Any? = null): Event {
return Event(
roomId = roomId,
originServerTs = dummyOriginServerTs(),
sender = credentials.userId,
eventId = dummyEventId(roomId),
type = EventType.MESSAGE,
content = content.toContent()
)
}

private fun dummyOriginServerTs(): Long {
return System.currentTimeMillis()
}

private fun dummyEventId(roomId: String): String {
return roomId + "-" + dummyOriginServerTs()
}
}

View File

@ -20,15 +20,11 @@ import android.content.Context
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import com.zhuinden.monarchy.Monarchy
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.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.di.MatrixKoinComponent import im.vector.matrix.android.internal.di.MatrixKoinComponent
import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.room.RoomAPI import im.vector.matrix.android.internal.session.room.RoomAPI
import im.vector.matrix.android.internal.util.WorkerParamsFactory import im.vector.matrix.android.internal.util.WorkerParamsFactory
import im.vector.matrix.android.internal.util.tryTransactionSync
import org.koin.standalone.inject import org.koin.standalone.inject


internal class SendEventWorker(context: Context, params: WorkerParameters) internal class SendEventWorker(context: Context, params: WorkerParameters)
@ -42,33 +38,25 @@ internal class SendEventWorker(context: Context, params: WorkerParameters)
) )


private val roomAPI by inject<RoomAPI>() private val roomAPI by inject<RoomAPI>()
private val monarchy by inject<Monarchy>()


override fun doWork(): Result { override fun doWork(): Result {


val params = WorkerParamsFactory.fromData<Params>(inputData) val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.failure() ?: return Result.failure()


if (params.event.eventId == null) { val localEvent = params.event
if (localEvent.eventId == null) {
return Result.failure() return Result.failure()
} }


val result = executeRequest<SendResponse> { val result = executeRequest<SendResponse> {
apiCall = roomAPI.send( apiCall = roomAPI.send(
params.event.eventId, localEvent.eventId,
params.roomId, params.roomId,
params.event.type, localEvent.type,
params.event.content localEvent.content
) )
} }
result.flatMap { sendResponse ->
monarchy.tryTransactionSync { realm ->
val dummyEventEntity = EventEntity.where(realm, params.event.eventId).findFirst()
dummyEventEntity?.eventId = sendResponse.eventId
}
}
return result.fold({ Result.retry() }, { Result.success() }) return result.fold({ Result.retry() }, { Result.success() })
} }


} }

View File

@ -27,25 +27,19 @@ 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.TimelineEvent
import im.vector.matrix.android.api.util.CancelableBag import im.vector.matrix.android.api.util.CancelableBag
import im.vector.matrix.android.api.util.addTo import im.vector.matrix.android.api.util.addTo
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.*
import im.vector.matrix.android.internal.database.model.ChunkEntityFields
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.EventEntityFields
import im.vector.matrix.android.internal.database.query.findIncludingEvent import im.vector.matrix.android.internal.database.query.findIncludingEvent
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.task.configureWith
import io.realm.OrderedRealmCollectionChangeListener import im.vector.matrix.android.internal.util.Debouncer
import io.realm.Realm import io.realm.*
import io.realm.RealmConfiguration
import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.Sort
import timber.log.Timber import timber.log.Timber
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import kotlin.collections.ArrayList




private const val INITIAL_LOAD_SIZE = 20 private const val INITIAL_LOAD_SIZE = 20
@ -68,8 +62,7 @@ internal class DefaultTimeline(
set(value) { set(value) {
field = value field = value
backgroundHandler.get()?.post { backgroundHandler.get()?.post {
val snapshot = snapshot() postSnapshot()
mainHandler.post { listener?.onUpdated(snapshot) }
} }
} }


@ -80,41 +73,46 @@ internal class DefaultTimeline(
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val backgroundRealm = AtomicReference<Realm>() private val backgroundRealm = AtomicReference<Realm>()
private val cancelableBag = CancelableBag() private val cancelableBag = CancelableBag()
private val debouncer = Debouncer(mainHandler)


private lateinit var liveEvents: RealmResults<EventEntity> private lateinit var liveEvents: RealmResults<EventEntity>
private var roomEntity: RoomEntity? = null


private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN
private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN
private val isLive = initialEventId == null private val isLive = initialEventId == null
private val builtEvents = Collections.synchronizedList<TimelineEvent>(ArrayList()) private val builtEvents = Collections.synchronizedList<TimelineEvent>(ArrayList())

private val backwardsPaginationState = AtomicReference(PaginationState()) private val backwardsPaginationState = AtomicReference(PaginationState())
private val forwardsPaginationState = AtomicReference(PaginationState()) private val forwardsPaginationState = AtomicReference(PaginationState())



private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<EventEntity>> { _, changeSet -> private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<EventEntity>> { _, changeSet ->
// TODO HANDLE CHANGES if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL) {
changeSet.insertionRanges.forEach { range -> handleInitialLoad()
val (startDisplayIndex, direction) = if (range.startIndex == 0) { } else {
Pair(liveEvents[range.length - 1]!!.displayIndex, Timeline.Direction.FORWARDS) changeSet.insertionRanges.forEach { range ->
} else { val (startDisplayIndex, direction) = if (range.startIndex == 0) {
Pair(liveEvents[range.startIndex]!!.displayIndex, Timeline.Direction.BACKWARDS) Pair(liveEvents[range.length - 1]!!.displayIndex, Timeline.Direction.FORWARDS)
} } else {
val state = getPaginationState(direction) Pair(liveEvents[range.startIndex]!!.displayIndex, Timeline.Direction.BACKWARDS)
if (state.isPaginating) { }
// We are getting new items from pagination val state = getPaginationState(direction)
val shouldPostSnapshot = paginateInternal(startDisplayIndex, direction, state.requestedCount) if (state.isPaginating) {
if (shouldPostSnapshot) { // We are getting new items from pagination
val shouldPostSnapshot = paginateInternal(startDisplayIndex, direction, state.requestedCount)
if (shouldPostSnapshot) {
postSnapshot()
}
} else {
// We are getting new items from sync
buildTimelineEvents(startDisplayIndex, direction, range.length.toLong())
postSnapshot() postSnapshot()
} }
} else {
// We are getting new items from sync
buildTimelineEvents(startDisplayIndex, direction, range.length.toLong())
postSnapshot()
} }
} }
} }


// Public methods ****************************************************************************** // Public methods ******************************************************************************


override fun paginate(direction: Timeline.Direction, count: Int) { override fun paginate(direction: Timeline.Direction, count: Int) {
backgroundHandler.get()?.post { backgroundHandler.get()?.post {
@ -142,12 +140,19 @@ internal class DefaultTimeline(
val realm = Realm.getInstance(realmConfiguration) val realm = Realm.getInstance(realmConfiguration)
backgroundRealm.set(realm) backgroundRealm.set(realm)
clearUnlinkedEvents(realm) clearUnlinkedEvents(realm)
isReady.set(true)
roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()?.also {
it.sendingTimelineEvents.addChangeListener { _ ->
postSnapshot()
}
}

liveEvents = buildEventQuery(realm) liveEvents = buildEventQuery(realm)
.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) .sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
.findAll() .findAllAsync()
.also { it.addChangeListener(eventsChangeListener) } .also { it.addChangeListener(eventsChangeListener) }
handleInitialLoad()
isReady.set(true)
} }
} }
} }
@ -171,7 +176,7 @@ internal class DefaultTimeline(
return hasMoreInCache(direction) || !hasReachedEnd(direction) return hasMoreInCache(direction) || !hasReachedEnd(direction)
} }


// Private methods ***************************************************************************** // Private methods *****************************************************************************


private fun hasMoreInCache(direction: Timeline.Direction): Boolean { private fun hasMoreInCache(direction: Timeline.Direction): Boolean {
val localRealm = Realm.getInstance(realmConfiguration) val localRealm = Realm.getInstance(realmConfiguration)
@ -203,7 +208,7 @@ internal class DefaultTimeline(


/** /**
* This has to be called on TimelineThread as it access realm live results * This has to be called on TimelineThread as it access realm live results
* @return true if snapshot should be posted * @return true if createSnapshot should be posted
*/ */
private fun paginateInternal(startDisplayIndex: Int, private fun paginateInternal(startDisplayIndex: Int,
direction: Timeline.Direction, direction: Timeline.Direction,
@ -222,8 +227,19 @@ internal class DefaultTimeline(
return !shouldFetchMore return !shouldFetchMore
} }


private fun snapshot(): List<TimelineEvent> { private fun createSnapshot(): List<TimelineEvent> {
return builtEvents.toList() return buildSendingEvents() + builtEvents.toList()
}

private fun buildSendingEvents(): List<TimelineEvent> {
val sendingEvents = ArrayList<TimelineEvent>()
if (hasReachedEnd(Timeline.Direction.FORWARDS)) {
roomEntity?.sendingTimelineEvents?.forEach {
val timelineEvent = timelineEventFactory.create(it)
sendingEvents.add(timelineEvent)
}
}
return sendingEvents
} }


private fun canPaginate(direction: Timeline.Direction): Boolean { private fun canPaginate(direction: Timeline.Direction): Boolean {
@ -282,9 +298,9 @@ internal class DefaultTimeline(
private fun executePaginationTask(direction: Timeline.Direction, limit: Int) { private fun executePaginationTask(direction: Timeline.Direction, limit: Int) {
val token = getTokenLive(direction) ?: return val token = getTokenLive(direction) ?: return
val params = PaginationTask.Params(roomId = roomId, val params = PaginationTask.Params(roomId = roomId,
from = token, from = token,
direction = direction.toPaginationDirection(), direction = direction.toPaginationDirection(),
limit = limit) limit = limit)


Timber.v("Should fetch $limit items $direction") Timber.v("Should fetch $limit items $direction")
paginationTask.configureWith(params) paginationTask.configureWith(params)
@ -414,8 +430,9 @@ internal class DefaultTimeline(
} }


private fun postSnapshot() { private fun postSnapshot() {
val snapshot = snapshot() val snapshot = createSnapshot()
mainHandler.post { listener?.onUpdated(snapshot) } val runnable = Runnable { listener?.onUpdated(snapshot) }
debouncer.debounce("post_snapshot", runnable, 50)
} }


// Extension methods *************************************************************************** // Extension methods ***************************************************************************

View File

@ -29,7 +29,8 @@ internal class TimelineEventFactory(private val roomMemberExtractor: RoomMemberE
eventEntity.asDomain(), eventEntity.asDomain(),
eventEntity.localId, eventEntity.localId,
eventEntity.displayIndex, eventEntity.displayIndex,
roomMember roomMember,
eventEntity.sendState
) )
} }



View File

@ -28,6 +28,7 @@ import im.vector.matrix.android.internal.database.helper.addStateEvents
import im.vector.matrix.android.internal.database.helper.lastStateIndex import im.vector.matrix.android.internal.database.helper.lastStateIndex
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.find
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater
@ -108,6 +109,15 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
timelineStateOffset timelineStateOffset
) )
roomEntity.addOrUpdate(chunkEntity) roomEntity.addOrUpdate(chunkEntity)

// Try to remove local echo
val transactionIds = roomSync.timeline.events.mapNotNull { it.unsignedData?.transactionId }
transactionIds.forEach {
val sendingEventEntity = roomEntity.sendingTimelineEvents.find(it)
if (sendingEventEntity != null) {
roomEntity.sendingTimelineEvents.remove(sendingEventEntity)
}
}
} }
roomSummaryUpdater.update(realm, roomId, roomSync.summary, roomSync.unreadNotifications) roomSummaryUpdater.update(realm, roomId, roomSync.summary, roomSync.unreadNotifications)


@ -161,7 +171,6 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
} else { } else {
realm.createObject<ChunkEntity>().apply { this.prevToken = prevToken } realm.createObject<ChunkEntity>().apply { this.prevToken = prevToken }
} }

lastChunk?.isLastForward = false lastChunk?.isLastForward = false
chunkEntity.isLastForward = true chunkEntity.isLastForward = true
chunkEntity.addAll(roomId, eventList, PaginationDirection.FORWARDS, stateIndexOffset) chunkEntity.addAll(roomId, eventList, PaginationDirection.FORWARDS, stateIndexOffset)

View File

@ -0,0 +1,45 @@
/*
*
* * 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.util

import android.os.Handler

internal class Debouncer(private val handler: Handler) {

private val runnables = HashMap<String, Runnable>()

fun debounce(identifier: String, r: Runnable, millis: Long): Boolean {
if (runnables.containsKey(identifier)) {
// debounce
val old = runnables[identifier]
handler.removeCallbacks(old)
}
insertRunnable(identifier, r, millis)
return true
}

private fun insertRunnable(identifier: String, r: Runnable, millis: Long) {
val chained = Runnable {
handler.post(r)
runnables.remove(identifier)
}
runnables[identifier] = chained
handler.postDelayed(chained, millis)
}
}

View File

@ -123,7 +123,7 @@ dependencies {
def coroutines_version = "1.0.1" def coroutines_version = "1.0.1"
def markwon_version = '3.0.0-SNAPSHOT' def markwon_version = '3.0.0-SNAPSHOT'
def big_image_viewer_version = '1.5.6' def big_image_viewer_version = '1.5.6'
def glide_version = '4.8.0' def glide_version = '4.9.0'


implementation project(":matrix-sdk-android") implementation project(":matrix-sdk-android")
implementation project(":matrix-sdk-android-rx") implementation project(":matrix-sdk-android-rx")
@ -191,6 +191,9 @@ dependencies {
// Badge for compatibility // Badge for compatibility
implementation 'me.leolin:ShortcutBadger:1.1.2@aar' implementation 'me.leolin:ShortcutBadger:1.1.2@aar'


// File picker
implementation 'com.github.jaiselrahman:FilePicker:1.2.2'

// DI // DI
implementation "org.koin:koin-android:$koin_version" implementation "org.koin:koin-android:$koin_version"
implementation "org.koin:koin-android-scope:$koin_version" implementation "org.koin:koin-android-scope:$koin_version"

View File

@ -0,0 +1,41 @@
/*
* 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.riotredesign.core.dialogs

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import im.vector.riotredesign.R

internal abstract class DialogAdapter(context: Context) : ArrayAdapter<DialogListItem>(context, R.layout.item_dialog) {

override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
var view = convertView
if (view == null) {
view = LayoutInflater.from(context).inflate(R.layout.item_dialog, parent, false)
view.tag = DialogListItemHolder(view)
}
(view!!.tag as DialogListItemHolder).let {
it.icon.setImageResource(getItem(position).iconRes)
it.text.setText(getItem(position).titleRes)
}
return view
}

}

View File

@ -0,0 +1,29 @@
/*
* 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.riotredesign.core.dialogs

import android.content.Context
import im.vector.riotredesign.core.dialogs.DialogAdapter
import im.vector.riotredesign.core.dialogs.DialogListItem

internal class DialogCallAdapter(context: Context) : DialogAdapter(context) {

init {
add(DialogListItem.StartVoiceCall)
add(DialogListItem.StartVideoCall)
}
}

View File

@ -0,0 +1,36 @@
/*
* 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.riotredesign.core.dialogs

import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import im.vector.riotredesign.R

internal sealed class DialogListItem(@DrawableRes val iconRes: Int,
@StringRes val titleRes: Int) {

object StartVoiceCall : DialogListItem(R.drawable.voice_call_green, R.string.action_voice_call)
object StartVideoCall : DialogListItem(R.drawable.video_call_green, R.string.action_video_call)

object SendFile : DialogListItem(R.drawable.ic_material_file, R.string.option_send_files)
object SendVoice : DialogListItem(R.drawable.vector_micro_green, R.string.option_send_voice)
object SendSticker : DialogListItem(R.drawable.ic_send_sticker, R.string.option_send_sticker)
object TakePhoto : DialogListItem(R.drawable.ic_material_camera, R.string.option_take_photo)
object TakeVideo : DialogListItem(R.drawable.ic_material_videocam, R.string.option_take_video)
object TakePhotoVideo : DialogListItem(R.drawable.ic_material_camera, R.string.option_take_photo_video)

}

View File

@ -0,0 +1,37 @@
/*
* 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.riotredesign.core.dialogs

import android.view.View
import android.widget.ImageView
import android.widget.TextView
import butterknife.BindView
import butterknife.ButterKnife
import im.vector.riotredesign.R

class DialogListItemHolder(view: View) {

@BindView(R.id.adapter_item_dialog_icon)
lateinit var icon: ImageView

@BindView(R.id.adapter_item_dialog_text)
lateinit var text: TextView

init {
ButterKnife.bind(this, view)
}
}

View File

@ -0,0 +1,66 @@
/*
* 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.riotredesign.core.dialogs

import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import im.vector.riotredesign.core.platform.Restorable
import timber.log.Timber

private const val KEY_DIALOG_IS_DISPLAYED = "DialogLocker.KEY_DIALOG_IS_DISPLAYED"

/**
* Class to avoid displaying twice the same dialog
*/
class DialogLocker() : Restorable {

private var isDialogDisplayed: Boolean = false

private fun unlock() {
isDialogDisplayed = false
}

private fun lock() {
isDialogDisplayed = true
}

fun displayDialog(builder: () -> AlertDialog.Builder): AlertDialog? {
return if (isDialogDisplayed) {
Timber.w("Filtered dialog request")
null
} else {
builder
.invoke()
.create()
.apply {
setOnShowListener { lock() }
setOnCancelListener { unlock() }
setOnDismissListener { unlock() }
show()
}
}
}

override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(KEY_DIALOG_IS_DISPLAYED, isDialogDisplayed)
}

override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
isDialogDisplayed = savedInstanceState?.getBoolean(KEY_DIALOG_IS_DISPLAYED, false) == true
}

}

View File

@ -0,0 +1,28 @@
/*
* 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.riotredesign.core.dialogs

import android.content.Context
import im.vector.riotredesign.core.dialogs.DialogAdapter
import im.vector.riotredesign.core.dialogs.DialogListItem

internal class DialogSendItemAdapter(context: Context, items: MutableList<DialogListItem>) : DialogAdapter(context) {

init {
addAll(items)
}
}

View File

@ -0,0 +1,84 @@
/*
* 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.riotredesign.core.dialogs

import android.app.Activity
import android.text.Editable
import android.text.TextUtils
import android.text.TextWatcher
import android.widget.Button
import androidx.appcompat.app.AlertDialog
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import im.vector.riotredesign.R

class ExportKeysDialog {

fun show(activity: Activity, exportKeyDialogListener: ExportKeyDialogListener) {
val dialogLayout = activity.layoutInflater.inflate(R.layout.dialog_export_e2e_keys, null)
val builder = AlertDialog.Builder(activity)
.setTitle(R.string.encryption_export_room_keys)
.setView(dialogLayout)

val passPhrase1EditText = dialogLayout.findViewById<TextInputEditText>(R.id.dialog_e2e_keys_passphrase_edit_text)
val passPhrase2EditText = dialogLayout.findViewById<TextInputEditText>(R.id.dialog_e2e_keys_confirm_passphrase_edit_text)
val passPhrase2Til = dialogLayout.findViewById<TextInputLayout>(R.id.dialog_e2e_keys_confirm_passphrase_til)
val exportButton = dialogLayout.findViewById<Button>(R.id.dialog_e2e_keys_export_button)
val textWatcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {

}

override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {

}

override fun afterTextChanged(s: Editable) {
when {
TextUtils.isEmpty(passPhrase1EditText.text) -> {
exportButton.isEnabled = false
passPhrase2Til.error = null
}
TextUtils.equals(passPhrase1EditText.text, passPhrase2EditText.text) -> {
exportButton.isEnabled = true
passPhrase2Til.error = null
}
else -> {
exportButton.isEnabled = false
passPhrase2Til.error = activity.getString(R.string.passphrase_passphrase_does_not_match)
}
}
}
}

passPhrase1EditText.addTextChangedListener(textWatcher)
passPhrase2EditText.addTextChangedListener(textWatcher)

val exportDialog = builder.show()

exportButton.setOnClickListener {
exportKeyDialogListener.onPassphrase(passPhrase1EditText.text.toString())

exportDialog.dismiss()
}
}


interface ExportKeyDialogListener {
fun onPassphrase(passphrase: String)
}
}

View File

@ -0,0 +1,240 @@
/*
*
* * 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.riotredesign.core.utils

import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Browser
import android.provider.MediaStore
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import im.vector.riotredesign.BuildConfig
import im.vector.riotredesign.R
import timber.log.Timber
import java.io.File
import java.text.SimpleDateFormat
import java.util.*

/**
* Open a url in the internet browser of the system
*/
fun openUrlInExternalBrowser(context: Context, url: String?) {
url?.let {
openUrlInExternalBrowser(context, Uri.parse(it))
}
}

/**
* Open a uri in the internet browser of the system
*/
fun openUrlInExternalBrowser(context: Context, uri: Uri?) {
uri?.let {
val browserIntent = Intent(Intent.ACTION_VIEW, it).apply {
putExtra(Browser.EXTRA_APPLICATION_ID, context.packageName)
}

try {
context.startActivity(browserIntent)
} catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(R.string.error_no_external_application_found)
}
}
}

/**
* Open sound recorder external application
*/
fun openSoundRecorder(activity: Activity, requestCode: Int) {
val recordSoundIntent = Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION)

// Create chooser
val chooserIntent = Intent.createChooser(recordSoundIntent, activity.getString(R.string.go_on_with))

try {
activity.startActivityForResult(chooserIntent, requestCode)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

/**
* Open file selection activity
*/
fun openFileSelection(activity: Activity,
fragment: Fragment?,
allowMultipleSelection: Boolean,
requestCode: Int) {
val fileIntent = Intent(Intent.ACTION_GET_CONTENT)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
fileIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultipleSelection)
}

fileIntent.addCategory(Intent.CATEGORY_OPENABLE)
fileIntent.type = "*/*"

try {
fragment
?.startActivityForResult(fileIntent, requestCode)
?: run {
activity.startActivityForResult(fileIntent, requestCode)
}
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

/**
* Open external video recorder
*/
fun openVideoRecorder(activity: Activity, requestCode: Int) {
val captureIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE)

// lowest quality
captureIntent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 0)

try {
activity.startActivityForResult(captureIntent, requestCode)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

/**
* Open external camera
* @return the latest taken picture camera uri
*/
fun openCamera(activity: Activity, titlePrefix: String, requestCode: Int): String? {
val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)

// the following is a fix for buggy 2.x devices
val date = Date()
val formatter = SimpleDateFormat("yyyyMMddHHmmss", Locale.US)
val values = ContentValues()
values.put(MediaStore.Images.Media.TITLE, titlePrefix + formatter.format(date))
// The Galaxy S not only requires the name of the file to output the image to, but will also not
// set the mime type of the picture it just took (!!!). We assume that the Galaxy S takes image/jpegs
// so the attachment uploader doesn't freak out about there being no mimetype in the content database.
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
var dummyUri: Uri? = null
try {
dummyUri = activity.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)

if (null == dummyUri) {
Timber.e("Cannot use the external storage media to save image")
}
} catch (uoe: UnsupportedOperationException) {
Timber.e(uoe, "Unable to insert camera URI into MediaStore.Images.Media.EXTERNAL_CONTENT_URI " +
"no SD card? Attempting to insert into device storage.")
} catch (e: Exception) {
Timber.e(e, "Unable to insert camera URI into MediaStore.Images.Media.EXTERNAL_CONTENT_URI. $e")
}

if (null == dummyUri) {
try {
dummyUri = activity.contentResolver.insert(MediaStore.Images.Media.INTERNAL_CONTENT_URI, values)
if (null == dummyUri) {
Timber.e("Cannot use the internal storage to save media to save image")
}

} catch (e: Exception) {
Timber.e(e, "Unable to insert camera URI into internal storage. Giving up. $e")
}
}

if (dummyUri != null) {
captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, dummyUri)
Timber.d("trying to take a photo on " + dummyUri.toString())
} else {
Timber.d("trying to take a photo with no predefined uri")
}

// Store the dummy URI which will be set to a placeholder location. When all is lost on Samsung devices,
// this will point to the data we're looking for.
// Because Activities tend to use a single MediaProvider for all their intents, this field will only be the
// *latest* TAKE_PICTURE Uri. This is deemed acceptable as the normal flow is to create the intent then immediately
// fire it, meaning onActivityResult/getUri will be the next thing called, not another createIntentFor.
val result = if (dummyUri == null) null else dummyUri.toString()

try {
activity.startActivityForResult(captureIntent, requestCode)

return result
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}

return null
}

/**
* Send an email to address with optional subject and message
*/
fun sendMailTo(address: String, subject: String? = null, message: String? = null, activity: Activity) {
val intent = Intent(Intent.ACTION_SENDTO, Uri.fromParts(
"mailto", address, null))
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
intent.putExtra(Intent.EXTRA_TEXT, message)

try {
activity.startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

/**
* Open an arbitrary uri
*/
fun openUri(activity: Activity, uri: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))

try {
activity.startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

/**
* Send media to a third party application.
*
* @param activity the activity
* @param savedMediaPath the media path
* @param mimeType the media mime type.
*/
fun openMedia(activity: Activity, savedMediaPath: String, mimeType: String) {
val file = File(savedMediaPath)
val uri = FileProvider.getUriForFile(activity, BuildConfig.APPLICATION_ID + ".fileProvider", file)

val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

try {
activity.startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

View File

@ -0,0 +1,397 @@
/*
* 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.riotredesign.core.utils

import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.text.TextUtils
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import im.vector.riotredesign.R
import timber.log.Timber
import java.util.*


private const val LOG_TAG = "PermissionUtils"

// Android M permission request code management
private const val PERMISSIONS_GRANTED = true
private const val PERMISSIONS_DENIED = !PERMISSIONS_GRANTED

// Permission bit
private const val PERMISSION_BYPASSED = 0x0
const val PERMISSION_CAMERA = 0x1
private const val PERMISSION_WRITE_EXTERNAL_STORAGE = 0x1 shl 1
private const val PERMISSION_RECORD_AUDIO = 0x1 shl 2
private const val PERMISSION_READ_CONTACTS = 0x1 shl 3

// Permissions sets
const val PERMISSIONS_FOR_AUDIO_IP_CALL = PERMISSION_RECORD_AUDIO
const val PERMISSIONS_FOR_VIDEO_IP_CALL = PERMISSION_CAMERA or PERMISSION_RECORD_AUDIO
const val PERMISSIONS_FOR_TAKING_PHOTO = PERMISSION_CAMERA or PERMISSION_WRITE_EXTERNAL_STORAGE
const val PERMISSIONS_FOR_MEMBERS_SEARCH = PERMISSION_READ_CONTACTS
const val PERMISSIONS_FOR_MEMBER_DETAILS = PERMISSION_READ_CONTACTS
const val PERMISSIONS_FOR_ROOM_AVATAR = PERMISSION_CAMERA
const val PERMISSIONS_FOR_VIDEO_RECORDING = PERMISSION_CAMERA or PERMISSION_RECORD_AUDIO
const val PERMISSIONS_FOR_WRITING_FILES = PERMISSION_WRITE_EXTERNAL_STORAGE

private const val PERMISSIONS_EMPTY = PERMISSION_BYPASSED

// Request code to ask permission to the system (arbitrary values)
const val PERMISSION_REQUEST_CODE = 567
const val PERMISSION_REQUEST_CODE_LAUNCH_CAMERA = 568
const val PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA = 569
const val PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA = 570
const val PERMISSION_REQUEST_CODE_AUDIO_CALL = 571
const val PERMISSION_REQUEST_CODE_VIDEO_CALL = 572
const val PERMISSION_REQUEST_CODE_EXPORT_KEYS = 573
const val PERMISSION_REQUEST_CODE_CHANGE_AVATAR = 574

/**
* Log the used permissions statuses.
*/
fun logPermissionStatuses(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val permissions = Arrays.asList(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_CONTACTS)

Timber.d("## logPermissionStatuses() : log the permissions status used by the app")

for (permission in permissions) {
Timber.d(("Status of [$permission] : " +
if (PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission(context, permission))
"PERMISSION_GRANTED"
else
"PERMISSION_DENIED"))
}
}
}


/**
* See [.checkPermissions]
*
* @param permissionsToBeGrantedBitMap
* @param activity
* @return true if the permissions are granted (synchronous flow), false otherwise (asynchronous flow)
*/
fun checkPermissions(permissionsToBeGrantedBitMap: Int,
activity: Activity,
requestCode: Int = PERMISSION_REQUEST_CODE): Boolean {
return checkPermissions(permissionsToBeGrantedBitMap, activity, null, requestCode)
}

/**
* See [.checkPermissions]
*
* @param permissionsToBeGrantedBitMap
* @param fragment
* @return true if the permissions are granted (synchronous flow), false otherwise (asynchronous flow)
*/
fun checkPermissions(permissionsToBeGrantedBitMap: Int,
fragment: Fragment,
requestCode: Int = PERMISSION_REQUEST_CODE): Boolean {
return checkPermissions(permissionsToBeGrantedBitMap, fragment.activity, fragment, requestCode)
}

/**
* Check if the permissions provided in the list are granted.
* This is an asynchronous method if permissions are requested, the final response
* is provided in onRequestPermissionsResult(). In this case checkPermissions()
* returns false.
* <br></br>If checkPermissions() returns true, the permissions were already granted.
* The permissions to be granted are given as bit map in permissionsToBeGrantedBitMap (ex: [.PERMISSIONS_FOR_TAKING_PHOTO]).
* <br></br>permissionsToBeGrantedBitMap is passed as the request code in onRequestPermissionsResult().
*
*
* If a permission was already denied by the user, a popup is displayed to
* explain why vector needs the corresponding permission.
*
* @param permissionsToBeGrantedBitMap the permissions bit map to be granted
* @param activity the calling Activity that is requesting the permissions (or fragment parent)
* @param fragment the calling fragment that is requesting the permissions
* @return true if the permissions are granted (synchronous flow), false otherwise (asynchronous flow)
*/
private fun checkPermissions(permissionsToBeGrantedBitMap: Int,
activity: Activity?,
fragment: Fragment?,
requestCode: Int): Boolean {
var isPermissionGranted = false

// sanity check
if (null == activity) {
Timber.w("## checkPermissions(): invalid input data")
isPermissionGranted = false
} else if (PERMISSIONS_EMPTY == permissionsToBeGrantedBitMap) {
isPermissionGranted = true
} else if (PERMISSIONS_FOR_AUDIO_IP_CALL != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_VIDEO_IP_CALL != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_TAKING_PHOTO != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_MEMBERS_SEARCH != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_MEMBER_DETAILS != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_ROOM_AVATAR != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_VIDEO_RECORDING != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_WRITING_FILES != permissionsToBeGrantedBitMap) {
Timber.w("## checkPermissions(): permissions to be granted are not supported")
isPermissionGranted = false
} else {
val permissionListAlreadyDenied = ArrayList<String>()
val permissionsListToBeGranted = ArrayList<String>()
var isRequestPermissionRequired = false
var explanationMessage = ""

// retrieve the permissions to be granted according to the request code bit map
if (PERMISSION_CAMERA == permissionsToBeGrantedBitMap and PERMISSION_CAMERA) {
val permissionType = Manifest.permission.CAMERA
isRequestPermissionRequired = isRequestPermissionRequired or
updatePermissionsToBeGranted(activity, permissionListAlreadyDenied, permissionsListToBeGranted, permissionType)
}

if (PERMISSION_RECORD_AUDIO == permissionsToBeGrantedBitMap and PERMISSION_RECORD_AUDIO) {
val permissionType = Manifest.permission.RECORD_AUDIO
isRequestPermissionRequired = isRequestPermissionRequired or
updatePermissionsToBeGranted(activity, permissionListAlreadyDenied, permissionsListToBeGranted, permissionType)
}

if (PERMISSION_WRITE_EXTERNAL_STORAGE == permissionsToBeGrantedBitMap and PERMISSION_WRITE_EXTERNAL_STORAGE) {
val permissionType = Manifest.permission.WRITE_EXTERNAL_STORAGE
isRequestPermissionRequired = isRequestPermissionRequired or
updatePermissionsToBeGranted(activity, permissionListAlreadyDenied, permissionsListToBeGranted, permissionType)
}

// the contact book access is requested for any android platforms
// for android M, we use the system preferences
// for android < M, we use a dedicated settings
if (PERMISSION_READ_CONTACTS == permissionsToBeGrantedBitMap and PERMISSION_READ_CONTACTS) {
val permissionType = Manifest.permission.READ_CONTACTS

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
isRequestPermissionRequired = isRequestPermissionRequired or
updatePermissionsToBeGranted(activity, permissionListAlreadyDenied, permissionsListToBeGranted, permissionType)
} else {
// TODO uncomment
/*if (!ContactsManager.getInstance().isContactBookAccessRequested) {
isRequestPermissionRequired = true
permissionsListToBeGranted.add(permissionType)
}*/
}
}

// if some permissions were already denied: display a dialog to the user before asking again.
if (!permissionListAlreadyDenied.isEmpty()) {
if (permissionsToBeGrantedBitMap == PERMISSIONS_FOR_VIDEO_IP_CALL || permissionsToBeGrantedBitMap == PERMISSIONS_FOR_AUDIO_IP_CALL) {
// Permission request for VOIP call
if (permissionListAlreadyDenied.contains(Manifest.permission.CAMERA)
&& permissionListAlreadyDenied.contains(Manifest.permission.RECORD_AUDIO)) {
// Both missing
explanationMessage += activity.getString(R.string.permissions_rationale_msg_camera_and_audio)
} else if (permissionListAlreadyDenied.contains(Manifest.permission.RECORD_AUDIO)) {
// Audio missing
explanationMessage += activity.getString(R.string.permissions_rationale_msg_record_audio)
explanationMessage += activity.getString(R.string.permissions_rationale_msg_record_audio_explanation)
} else if (permissionListAlreadyDenied.contains(Manifest.permission.CAMERA)) {
// Camera missing
explanationMessage += activity.getString(R.string.permissions_rationale_msg_camera)
explanationMessage += activity.getString(R.string.permissions_rationale_msg_camera_explanation)
}
} else {
permissionListAlreadyDenied.forEach {
when (it) {
Manifest.permission.CAMERA -> {
if (!TextUtils.isEmpty(explanationMessage)) {
explanationMessage += "\n\n"
}
explanationMessage += activity.getString(R.string.permissions_rationale_msg_camera)
}
Manifest.permission.RECORD_AUDIO -> {
if (!TextUtils.isEmpty(explanationMessage)) {
explanationMessage += "\n\n"
}
explanationMessage += activity.getString(R.string.permissions_rationale_msg_record_audio)
}
Manifest.permission.WRITE_EXTERNAL_STORAGE -> {
if (!TextUtils.isEmpty(explanationMessage)) {
explanationMessage += "\n\n"
}
explanationMessage += activity.getString(R.string.permissions_rationale_msg_storage)
}
Manifest.permission.READ_CONTACTS -> {
if (!TextUtils.isEmpty(explanationMessage)) {
explanationMessage += "\n\n"
}
explanationMessage += activity.getString(R.string.permissions_rationale_msg_contacts)
}
else -> Timber.d("## checkPermissions(): already denied permission not supported")
}
}
}

// display the dialog with the info text
AlertDialog.Builder(activity)
.setTitle(R.string.permissions_rationale_popup_title)
.setMessage(explanationMessage)
.setOnCancelListener { Toast.makeText(activity, R.string.missing_permissions_warning, Toast.LENGTH_SHORT).show() }
.setPositiveButton(R.string.ok) { _, _ ->
if (!permissionsListToBeGranted.isEmpty()) {
fragment?.requestPermissions(permissionsListToBeGranted.toTypedArray(), requestCode)
?: run {
ActivityCompat.requestPermissions(activity, permissionsListToBeGranted.toTypedArray(), requestCode)
}
}
}
.show()
} else {
// some permissions are not granted, ask permissions
if (isRequestPermissionRequired) {
val permissionsArrayToBeGranted = permissionsListToBeGranted.toTypedArray()

// for android < M, we use a custom dialog to request the contacts book access.
/*
if (permissionsListToBeGranted.contains(Manifest.permission.READ_CONTACTS)
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
AlertDialog.Builder(activity)
.setIcon(android.R.drawable.ic_dialog_info)
.setTitle(R.string.permissions_rationale_popup_title)
.setMessage(R.string.permissions_msg_contacts_warning_other_androids)
// gives the contacts book access
.setPositiveButton(R.string.yes) { _, _ ->
ContactsManager.getInstance().setIsContactBookAccessAllowed(true)
fragment?.requestPermissions(permissionsArrayToBeGranted, requestCode)
?: run {
ActivityCompat.requestPermissions(activity, permissionsArrayToBeGranted, requestCode)
}
}
// or reject it
.setNegativeButton(R.string.no) { _, _ ->
ContactsManager.getInstance().setIsContactBookAccessAllowed(false)
fragment?.requestPermissions(permissionsArrayToBeGranted, requestCode)
?: run {
ActivityCompat.requestPermissions(activity, permissionsArrayToBeGranted, requestCode)
}
}
.show()
} else {
fragment?.requestPermissions(permissionsArrayToBeGranted, requestCode)
?: run {
ActivityCompat.requestPermissions(activity, permissionsArrayToBeGranted, requestCode)
}
}
*/
} else {
// permissions were granted, start now.
isPermissionGranted = true
}
}
}

return isPermissionGranted
}


/**
* Helper method used in [.checkPermissions] to populate the list of the
* permissions to be granted (permissionsListToBeGranted_out) and the list of the permissions already denied (permissionAlreadyDeniedList_out).
*
* @param activity calling activity
* @param permissionAlreadyDeniedList_out list to be updated with the permissions already denied by the user
* @param permissionsListToBeGranted_out list to be updated with the permissions to be granted
* @param permissionType the permission to be checked
* @return true if the permission requires to be granted, false otherwise
*/
private fun updatePermissionsToBeGranted(activity: Activity,
permissionAlreadyDeniedList_out: MutableList<String>,
permissionsListToBeGranted_out: MutableList<String>,
permissionType: String): Boolean {
var isRequestPermissionRequested = false

// add permission to be granted
permissionsListToBeGranted_out.add(permissionType)

if (PackageManager.PERMISSION_GRANTED != ContextCompat.checkSelfPermission(activity.applicationContext, permissionType)) {
isRequestPermissionRequested = true

// add permission to the ones that were already asked to the user
if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permissionType)) {
permissionAlreadyDeniedList_out.add(permissionType)
}
}
return isRequestPermissionRequested
}

/**
* Helper method to process [.PERMISSIONS_FOR_AUDIO_IP_CALL]
* on onRequestPermissionsResult() methods.
*
* @param context App context
* @param grantResults permissions granted results
* @return true if audio IP call is permitted, false otherwise
*/
fun onPermissionResultAudioIpCall(context: Context, grantResults: IntArray): Boolean {
val arePermissionsGranted = allGranted(grantResults)

if (!arePermissionsGranted) {
Toast.makeText(context, R.string.permissions_action_not_performed_missing_permissions, Toast.LENGTH_SHORT).show()
}

return arePermissionsGranted
}

/**
* Helper method to process [.PERMISSIONS_FOR_VIDEO_IP_CALL]
* on onRequestPermissionsResult() methods.
* For video IP calls, record audio and camera permissions are both mandatory.
*
* @param context App context
* @param grantResults permissions granted results
* @return true if video IP call is permitted, false otherwise
*/
fun onPermissionResultVideoIpCall(context: Context, grantResults: IntArray): Boolean {
val arePermissionsGranted = allGranted(grantResults)

if (!arePermissionsGranted) {
Toast.makeText(context, R.string.permissions_action_not_performed_missing_permissions, Toast.LENGTH_SHORT).show()
}

return arePermissionsGranted
}

/**
* Return true if all permissions are granted, false if not or if permission request has been cancelled
*/
fun allGranted(grantResults: IntArray): Boolean {
if (grantResults.isEmpty()) {
// A cancellation occurred
return false
}

var granted = true

grantResults.forEach {
granted = granted && PackageManager.PERMISSION_GRANTED == it
}

return granted
}

View File

@ -16,12 +16,14 @@


package im.vector.riotredesign.features.home.room.detail package im.vector.riotredesign.features.home.room.detail


import com.jaiselrahman.filepicker.model.MediaFile
import im.vector.matrix.android.api.session.room.timeline.Timeline 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.TimelineEvent


sealed class RoomDetailActions { sealed class RoomDetailActions {


data class SendMessage(val text: String) : RoomDetailActions() data class SendMessage(val text: String) : RoomDetailActions()
data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions()
object IsDisplayed : RoomDetailActions() object IsDisplayed : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions() data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions() data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions()

View File

@ -16,6 +16,8 @@


package im.vector.riotredesign.features.home.room.detail package im.vector.riotredesign.features.home.room.detail


import android.app.Activity.RESULT_OK
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.os.Bundle import android.os.Bundle
@ -28,6 +30,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.fragmentViewModel
import com.jaiselrahman.filepicker.activity.FilePickerActivity
import com.jaiselrahman.filepicker.config.Configurations
import com.jaiselrahman.filepicker.model.MediaFile
import com.otaliastudios.autocomplete.Autocomplete import com.otaliastudios.autocomplete.Autocomplete
import com.otaliastudios.autocomplete.AutocompleteCallback import com.otaliastudios.autocomplete.AutocompleteCallback
import com.otaliastudios.autocomplete.CharPolicy import com.otaliastudios.autocomplete.CharPolicy
@ -35,11 +40,18 @@ import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.user.model.User import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.dialogs.DialogListItem
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotredesign.core.extensions.observeEvent import im.vector.riotredesign.core.extensions.observeEvent
import im.vector.riotredesign.core.glide.GlideApp import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.core.platform.ToolbarConfigurable import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.core.platform.VectorBaseFragment import im.vector.riotredesign.core.platform.VectorBaseFragment
import im.vector.riotredesign.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA
import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA
import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA
import im.vector.riotredesign.core.utils.checkPermissions
import im.vector.riotredesign.core.utils.openCamera
import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandPresenter import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandPresenter
import im.vector.riotredesign.features.autocomplete.command.CommandAutocompletePolicy import im.vector.riotredesign.features.autocomplete.command.CommandAutocompletePolicy
import im.vector.riotredesign.features.autocomplete.user.AutocompleteUserPresenter import im.vector.riotredesign.features.autocomplete.user.AutocompleteUserPresenter
@ -61,6 +73,7 @@ import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope import org.koin.android.scope.ext.android.getOrCreateScope
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import timber.log.Timber




@Parcelize @Parcelize
@ -70,6 +83,10 @@ data class RoomDetailArgs(
) : Parcelable ) : Parcelable




private const val CAMERA_VALUE_TITLE = "attachment"
private const val REQUEST_FILES_REQUEST_CODE = 0
private const val TAKE_IMAGE_REQUEST_CODE = 1

class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callback, AutocompleteUserPresenter.Callback { class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callback, AutocompleteUserPresenter.Callback {


companion object { companion object {
@ -103,17 +120,28 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
setupRecyclerView() setupRecyclerView()
setupToolbar() setupToolbar()
setupComposer() setupComposer()
setupAttachmentButton()
roomDetailViewModel.subscribe { renderState(it) } roomDetailViewModel.subscribe { renderState(it) }
textComposerViewModel.subscribe { renderTextComposerState(it) } textComposerViewModel.subscribe { renderTextComposerState(it) }
roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) } roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) }
} }


override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == RESULT_OK && data != null) {
when (requestCode) {
REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data)
}
}
}


override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
roomDetailViewModel.process(RoomDetailActions.IsDisplayed) roomDetailViewModel.process(RoomDetailActions.IsDisplayed)
} }


// PRIVATE METHODS ***************************************************************************** // PRIVATE METHODS *****************************************************************************


private fun setupToolbar() { private fun setupToolbar() {
val parentActivity = vectorBaseActivity val parentActivity = vectorBaseActivity
@ -215,6 +243,77 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
} }
} }


private fun setupAttachmentButton() {
attachmentButton.setOnClickListener {
val intent = Intent(requireContext(), FilePickerActivity::class.java)
intent.putExtra(FilePickerActivity.CONFIGS, Configurations.Builder()
.setCheckPermission(true)
.setShowFiles(true)
.setShowAudios(true)
.setSkipZeroSizeFiles(true)
.build())
startActivityForResult(intent, REQUEST_FILES_REQUEST_CODE)
/*
val items = ArrayList<DialogListItem>()
// Send file
items.add(DialogListItem.SendFile)
// Send voice

if (PreferencesManager.isSendVoiceFeatureEnabled(this)) {
items.add(DialogListItem.SendVoice.INSTANCE)
}


// Send sticker
//items.add(DialogListItem.SendSticker)
// Camera

//if (PreferencesManager.useNativeCamera(this)) {
items.add(DialogListItem.TakePhoto)
items.add(DialogListItem.TakeVideo)
//} else {
// items.add(DialogListItem.TakePhotoVideo.INSTANCE)
// }
val adapter = DialogSendItemAdapter(requireContext(), items)
AlertDialog.Builder(requireContext())
.setAdapter(adapter) { _, position ->
onSendChoiceClicked(items[position])
}
.setNegativeButton(R.string.cancel, null)
.show()
*/
}
}

private fun onSendChoiceClicked(dialogListItem: DialogListItem) {
Timber.v("On send choice clicked: $dialogListItem")
when (dialogListItem) {
is DialogListItem.SendFile -> {
// launchFileIntent
}
is DialogListItem.SendVoice -> {
//launchAudioRecorderIntent()
}
is DialogListItem.SendSticker -> {
//startStickerPickerActivity()
}
is DialogListItem.TakePhotoVideo -> if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
// launchCamera()
}
is DialogListItem.TakePhoto -> if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA)) {
openCamera(requireActivity(), CAMERA_VALUE_TITLE, TAKE_IMAGE_REQUEST_CODE)
}
is DialogListItem.TakeVideo -> if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA)) {
// launchNativeVideoRecorder()
}
}
}

private fun handleMediaIntent(data: Intent) {
val files: ArrayList<MediaFile> = data.getParcelableArrayListExtra(FilePickerActivity.MEDIA_FILES)
roomDetailViewModel.process(RoomDetailActions.SendMedia(files))
}

private fun renderState(state: RoomDetailViewState) { private fun renderState(state: RoomDetailViewState) {
renderRoomSummary(state) renderRoomSummary(state)
timelineEventController.setTimeline(state.timeline) timelineEventController.setTimeline(state.timeline)
@ -240,20 +339,20 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) { private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
when (sendMessageResult) { when (sendMessageResult) {
is SendMessageResult.MessageSent, is SendMessageResult.MessageSent,
is SendMessageResult.SlashCommandHandled -> { is SendMessageResult.SlashCommandHandled -> {
// Clear composer // Clear composer
composerEditText.text = null composerEditText.text = null
} }
is SendMessageResult.SlashCommandError -> { is SendMessageResult.SlashCommandError -> {
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
} }
is SendMessageResult.SlashCommandUnknown -> { is SendMessageResult.SlashCommandUnknown -> {
displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command))
} }
is SendMessageResult.SlashCommandResultOk -> { is SendMessageResult.SlashCommandResultOk -> {
// Ignore // Ignore
} }
is SendMessageResult.SlashCommandResultError -> { is SendMessageResult.SlashCommandResultError -> {
displayCommandError(sendMessageResult.throwable.localizedMessage) displayCommandError(sendMessageResult.throwable.localizedMessage)
} }
is SendMessageResult.SlashCommandNotImplemented -> { is SendMessageResult.SlashCommandNotImplemented -> {
@ -270,7 +369,7 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
.show() .show()
} }


// TimelineEventController.Callback ************************************************************ // TimelineEventController.Callback ************************************************************


override fun onUrlClicked(url: String) { override fun onUrlClicked(url: String) {
homePermalinkHandler.launch(url) homePermalinkHandler.launch(url)
@ -285,7 +384,7 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
startActivity(intent) startActivity(intent)
} }


// AutocompleteUserPresenter.Callback // AutocompleteUserPresenter.Callback


override fun onQueryUsers(query: CharSequence?) { override fun onQueryUsers(query: CharSequence?) {
textComposerViewModel.process(TextComposerActions.QueryUsers(query)) textComposerViewModel.process(TextComposerActions.QueryUsers(query))

View File

@ -23,7 +23,7 @@ import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay import com.jakewharton.rxrelay2.BehaviorRelay
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.rx.rx import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.VectorViewModel import im.vector.riotredesign.core.platform.VectorViewModel
@ -69,10 +69,11 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,


fun process(action: RoomDetailActions) { fun process(action: RoomDetailActions) {
when (action) { when (action) {
is RoomDetailActions.SendMessage -> handleSendMessage(action) is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed() is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.SendMedia -> handleSendMedia(action)
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action) is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
is RoomDetailActions.LoadMore -> handleLoadMore(action) is RoomDetailActions.LoadMore -> handleLoadMore(action)
} }
} }


@ -87,63 +88,63 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
val slashCommandResult = CommandParser.parseSplashCommand(action.text) val slashCommandResult = CommandParser.parseSplashCommand(action.text)


when (slashCommandResult) { when (slashCommandResult) {
is ParsedCommand.ErrorNotACommand -> { is ParsedCommand.ErrorNotACommand -> {
// Send the text message to the room // Send the text message to the room
room.sendTextMessage(action.text, callback = object : MatrixCallback<Event> {}) room.sendTextMessage(action.text)
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
} }
is ParsedCommand.ErrorSyntax -> { is ParsedCommand.ErrorSyntax -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command))) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command)))
} }
is ParsedCommand.ErrorEmptySlashCommand -> { is ParsedCommand.ErrorEmptySlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown("/"))) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown("/")))
} }
is ParsedCommand.ErrorUnknownSlashCommand -> { is ParsedCommand.ErrorUnknownSlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown(slashCommandResult.slashCommand))) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown(slashCommandResult.slashCommand)))
} }
is ParsedCommand.Invite -> { is ParsedCommand.Invite -> {
handleInviteSlashCommand(slashCommandResult) handleInviteSlashCommand(slashCommandResult)
} }
is ParsedCommand.SetUserPowerLevel -> { is ParsedCommand.SetUserPowerLevel -> {
// TODO // TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
} }
is ParsedCommand.ClearScalarToken -> { is ParsedCommand.ClearScalarToken -> {
// TODO // TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
} }
is ParsedCommand.SetMarkdown -> { is ParsedCommand.SetMarkdown -> {
// TODO // TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
} }
is ParsedCommand.UnbanUser -> { is ParsedCommand.UnbanUser -> {
// TODO // TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
} }
is ParsedCommand.BanUser -> { is ParsedCommand.BanUser -> {
// TODO // TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
} }
is ParsedCommand.KickUser -> { is ParsedCommand.KickUser -> {
// TODO // TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
} }
is ParsedCommand.JoinRoom -> { is ParsedCommand.JoinRoom -> {
// TODO // TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
} }
is ParsedCommand.PartRoom -> { is ParsedCommand.PartRoom -> {
// TODO // TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
} }
is ParsedCommand.SendEmote -> { is ParsedCommand.SendEmote -> {
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE, callback = object : MatrixCallback<Event> {}) room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled))
} }
is ParsedCommand.ChangeTopic -> { is ParsedCommand.ChangeTopic -> {
handleChangeTopicSlashCommand(slashCommandResult) handleChangeTopicSlashCommand(slashCommandResult)
} }
is ParsedCommand.ChangeDisplayName -> { is ParsedCommand.ChangeDisplayName -> {
// TODO // TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
} }
@ -178,6 +179,23 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
}) })
} }


private fun handleSendMedia(action: RoomDetailActions.SendMedia) {
val attachments = action.mediaFiles.map {
ContentAttachmentData(
size = it.size,
duration = it.duration,
date = it.date,
height = it.height,
width = it.width,
name = it.name,
path = it.path,
mimeType = it.mimeType,
type = ContentAttachmentData.Type.values()[it.mediaType]
)
}
room.sendMedias(attachments)
}

private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) { private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) {
displayedEventsObservable.accept(action) displayedEventsObservable.accept(action)
} }
@ -216,4 +234,5 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
timeline.dispose() timeline.dispose()
super.onCleared() super.onCleared()
} }

} }

View File

@ -22,7 +22,12 @@ import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan 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.toModel import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageEmoteContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
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.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
@ -32,7 +37,13 @@ import im.vector.riotredesign.core.resources.ColorProvider
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.helper.TimelineDateFormatter import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.* import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem_
import im.vector.riotredesign.features.html.EventHtmlRenderer import im.vector.riotredesign.features.html.EventHtmlRenderer
import im.vector.riotredesign.features.media.MediaContentRenderer import im.vector.riotredesign.features.media.MediaContentRenderer
import me.gujun.android.span.span import me.gujun.android.span.span
@ -47,6 +58,7 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
callback: TimelineEventController.Callback? callback: TimelineEventController.Callback?
): VectorEpoxyModel<*>? { ): VectorEpoxyModel<*>? {


val eventId = event.root.eventId ?: return null
val roomMember = event.roomMember val roomMember = event.roomMember
val nextRoomMember = nextEvent?.roomMember val nextRoomMember = nextEvent?.roomMember


@ -54,12 +66,12 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
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


val showInformation = addDaySeparator val showInformation = addDaySeparator
|| nextRoomMember != roomMember || nextRoomMember != roomMember
|| nextEvent?.root?.type != EventType.MESSAGE || nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo || isNextMessageReceivedMoreThanOneHourAgo


val messageContent: MessageContent = event.root.content.toModel() ?: return null val messageContent: MessageContent = event.root.content.toModel() ?: return null
val time = timelineDateFormatter.formatMessageHour(date) val time = timelineDateFormatter.formatMessageHour(date)
@ -68,11 +80,12 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
val informationData = MessageInformationData(time, avatarUrl, memberName, showInformation) val informationData = MessageInformationData(time, avatarUrl, memberName, showInformation)


return when (messageContent) { return when (messageContent) {
is MessageTextContent -> buildTextMessageItem(messageContent, informationData, callback) is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, callback) is MessageTextContent -> buildTextMessageItem(event.sendState, messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback) is MessageImageContent -> buildImageMessageItem(eventId, messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, callback) is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, callback)
else -> buildNotHandledMessageItem(messageContent) else -> buildNotHandledMessageItem(messageContent)
} }
} }


@ -81,7 +94,8 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return DefaultItem_().text(text) return DefaultItem_().text(text)
} }


private fun buildImageMessageItem(messageContent: MessageImageContent, private fun buildImageMessageItem(eventId: String,
messageContent: MessageImageContent,
informationData: MessageInformationData, informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageImageItem? { callback: TimelineEventController.Callback?): MessageImageItem? {


@ -97,22 +111,30 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
orientation = messageContent.info?.orientation orientation = messageContent.info?.orientation
) )
return MessageImageItem_() return MessageImageItem_()
.eventId(eventId)
.informationData(informationData) .informationData(informationData)
.mediaData(data) .mediaData(data)
.clickListener { view -> callback?.onMediaClicked(data, view) } .clickListener { view -> callback?.onMediaClicked(data, view) }
} }


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


val bodyToUse = messageContent.formattedBody val bodyToUse = messageContent.formattedBody?.let {
?.let { htmlRenderer.render(it)
htmlRenderer.render(it) } ?: messageContent.body
}
?: messageContent.body


val linkifiedBody = linkifyBody(bodyToUse, callback) val textColor = if (sendState.isSent()) {
R.color.dark_grey
} else {
R.color.brown_grey
}
val formattedBody = span(bodyToUse) {
this.textColor = colorProvider.getColor(textColor)
}
val linkifiedBody = linkifyBody(formattedBody, callback)
return MessageTextItem_() return MessageTextItem_()
.message(linkifiedBody) .message(linkifiedBody)
.informationData(informationData) .informationData(informationData)

View File

@ -0,0 +1,104 @@
/*
* 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.riotredesign.features.home.room.detail.timeline.helper

import android.content.Context
import android.text.format.Formatter
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
import im.vector.riotredesign.R
import im.vector.riotredesign.features.media.MediaContentRenderer
import java.io.File

object ContentUploadStateTrackerBinder {

private val updateListeners = mutableMapOf<String, ContentUploadStateTracker.UpdateListener>()

fun bind(eventId: String,
mediaData: MediaContentRenderer.Data,
progressLayout: ViewGroup) {

Matrix.getInstance().currentSession?.also { session ->
val uploadStateTracker = session.contentUploadProgressTracker()
val updateListener = ContentMediaProgressUpdater(progressLayout, mediaData)
updateListeners[eventId] = updateListener
uploadStateTracker.track(eventId, updateListener)
}
}

fun unbind(eventId: String) {
Matrix.getInstance().currentSession?.also { session ->
val uploadStateTracker = session.contentUploadProgressTracker()
updateListeners[eventId]?.also {
uploadStateTracker.untrack(eventId, it)
}
}
}

}

private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
private val mediaData: MediaContentRenderer.Data) : ContentUploadStateTracker.UpdateListener {

override fun onUpdate(state: ContentUploadStateTracker.State) {
when (state) {
is ContentUploadStateTracker.State.Idle -> handleIdle(state)
is ContentUploadStateTracker.State.Failure -> handleFailure(state)
is ContentUploadStateTracker.State.Success -> handleSuccess(state)
is ContentUploadStateTracker.State.ProgressData -> handleProgress(state)
}
}

private fun handleIdle(state: ContentUploadStateTracker.State.Idle) {
if (mediaData.isLocalFile()) {
val file = File(mediaData.url)
progressLayout.visibility = View.VISIBLE
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
progressBar?.progress = 0
progressTextView?.text = formatStats(progressLayout.context, 0L, file.length())
} else {
progressLayout.visibility = View.GONE
}
}

private fun handleFailure(state: ContentUploadStateTracker.State.Failure) {

}

private fun handleSuccess(state: ContentUploadStateTracker.State.Success) {

}

private fun handleProgress(state: ContentUploadStateTracker.State.ProgressData) {
progressLayout.visibility = View.VISIBLE
val percent = 100L * (state.current.toFloat() / state.total.toFloat())
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
progressBar?.progress = percent.toInt()
progressTextView?.text = formatStats(progressLayout.context, state.current, state.total)
}

private fun formatStats(context: Context, current: Long, total: Long): String {
return "${Formatter.formatShortFileSize(context, current)} / ${Formatter.formatShortFileSize(context, total)}"
}

}

View File

@ -28,6 +28,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : VectorEpoxyModel<H>()
abstract val informationData: MessageInformationData abstract val informationData: MessageInformationData


override fun bind(holder: H) { override fun bind(holder: H) {
super.bind(holder)
if (informationData.showInformation) { if (informationData.showInformation) {
holder.avatarImageView.visibility = View.VISIBLE holder.avatarImageView.visibility = View.VISIBLE
holder.memberNameView.visibility = View.VISIBLE holder.memberNameView.visibility = View.VISIBLE

View File

@ -17,30 +17,42 @@
package im.vector.riotredesign.features.home.room.detail.timeline.item package im.vector.riotredesign.features.home.room.detail.timeline.item


import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.riotredesign.features.media.MediaContentRenderer import im.vector.riotredesign.features.media.MediaContentRenderer


@EpoxyModelClass(layout = R.layout.item_timeline_event_image_message) @EpoxyModelClass(layout = R.layout.item_timeline_event_image_message)
abstract class MessageImageItem : AbsMessageItem<MessageImageItem.Holder>() { abstract class MessageImageItem : AbsMessageItem<MessageImageItem.Holder>() {


@EpoxyAttribute lateinit var mediaData: MediaContentRenderer.Data @EpoxyAttribute lateinit var mediaData: MediaContentRenderer.Data
@EpoxyAttribute lateinit var eventId: String
@EpoxyAttribute override lateinit var informationData: MessageInformationData @EpoxyAttribute override lateinit var informationData: MessageInformationData
@EpoxyAttribute var clickListener: View.OnClickListener? = null @EpoxyAttribute var clickListener: View.OnClickListener? = null


override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
MediaContentRenderer.render(mediaData, MediaContentRenderer.Mode.THUMBNAIL, holder.imageView) MediaContentRenderer.render(mediaData, MediaContentRenderer.Mode.THUMBNAIL, holder.imageView)
ContentUploadStateTrackerBinder.bind(eventId, mediaData, holder.progressLayout)
holder.imageView.setOnClickListener(clickListener) holder.imageView.setOnClickListener(clickListener)
holder.imageView.isEnabled = !mediaData.isLocalFile()
holder.imageView.alpha = if (mediaData.isLocalFile()) 0.5f else 1f
}

override fun unbind(holder: Holder) {
ContentUploadStateTrackerBinder.unbind(eventId)
super.unbind(holder)
} }


class Holder : AbsMessageItem.Holder() { class Holder : AbsMessageItem.Holder() {
override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView) override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
override val memberNameView by bind<TextView>(R.id.messageMemberNameView) override val memberNameView by bind<TextView>(R.id.messageMemberNameView)
override val timeView by bind<TextView>(R.id.messageTimeView) override val timeView by bind<TextView>(R.id.messageTimeView)
val progressLayout by bind<ViewGroup>(R.id.messageImageUploadProgressLayout)
val imageView by bind<ImageView>(R.id.messageImageView) val imageView by bind<ImageView>(R.id.messageImageView)
} }



View File

@ -25,6 +25,7 @@ import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.riotredesign.core.glide.GlideApp import im.vector.riotredesign.core.glide.GlideApp
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import java.io.File


object MediaContentRenderer { object MediaContentRenderer {


@ -38,7 +39,12 @@ object MediaContentRenderer {
val maxWidth: Int, val maxWidth: Int,
val orientation: Int?, val orientation: Int?,
val rotation: Int? val rotation: Int?
) : Parcelable ) : Parcelable {

fun isLocalFile(): Boolean {
return url != null && File(url).exists()
}
}


enum class Mode { enum class Mode {
FULL_SIZE, FULL_SIZE,
@ -51,9 +57,11 @@ object MediaContentRenderer {
imageView.layoutParams.width = width imageView.layoutParams.width = width
val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver() val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver()
val resolvedUrl = when (mode) { val resolvedUrl = when (mode) {
Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url) Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url)
Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE) Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
} ?: return }
//Fallback to base url
?: data.url


GlideApp GlideApp
.with(imageView) .with(imageView)
@ -65,12 +73,16 @@ object MediaContentRenderer {
fun render(data: Data, imageView: BigImageView) { fun render(data: Data, imageView: BigImageView) {
val (width, height) = processSize(data, Mode.THUMBNAIL) val (width, height) = processSize(data, Mode.THUMBNAIL)
val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver() val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver()
val fullSize = contentUrlResolver.resolveFullSize(data.url) if (data.isLocalFile()) {
val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE) imageView.showImage(Uri.parse(data.url))
imageView.showImage( } else {
Uri.parse(thumbnail), val fullSize = contentUrlResolver.resolveFullSize(data.url)
Uri.parse(fullSize) val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
) imageView.showImage(
Uri.parse(thumbnail),
Uri.parse(fullSize)
)
}
} }


private fun processSize(data: Data, mode: Mode): Pair<Int, Int> { private fun processSize(data: Data, mode: Mode): Pair<Int, Int> {

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/layout_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="?dialogPreferredPadding"
android:paddingLeft="?dialogPreferredPadding"
android:paddingTop="12dp"
android:paddingEnd="?dialogPreferredPadding"
android:paddingRight="?dialogPreferredPadding"
android:paddingBottom="12dp">

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/encryption_export_notice"
android:textSize="16sp" />

<android.support.design.widget.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColorHint="?attr/vctr_default_text_hint_color">

<android.support.design.widget.TextInputEditText
android:id="@+id/dialog_e2e_keys_passphrase_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/passphrase_create_passphrase"
android:inputType="textPassword"
android:textColor="?android:textColorPrimary" />

</android.support.design.widget.TextInputLayout>


<android.support.design.widget.TextInputLayout
android:id="@+id/dialog_e2e_keys_confirm_passphrase_til"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textColorHint="?attr/vctr_default_text_hint_color"
app:errorEnabled="true">

<android.support.design.widget.TextInputEditText
android:id="@+id/dialog_e2e_keys_confirm_passphrase_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/passphrase_confirm_passphrase"
android:inputType="textPassword"
android:textColor="?android:textColorPrimary" />

</android.support.design.widget.TextInputLayout>

<Button
android:id="@+id/dialog_e2e_keys_export_button"
style="@style/VectorButtonStyle"
android:layout_width="match_parent"
android:enabled="false"
android:text="@string/encryption_export_export" />
</LinearLayout>

View File

@ -97,6 +97,17 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"> app:layout_constraintStart_toStartOf="parent">


<ImageButton
android:id="@+id/attachmentButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/sendButton"
android:layout_toLeftOf="@id/sendButton"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_attach_file_white"
android:tint="?attr/colorAccent" />

<ImageButton <ImageButton
android:id="@+id/sendButton" android:id="@+id/sendButton"
android:layout_width="48dp" android:layout_width="48dp"
@ -115,8 +126,8 @@
android:layout_alignParentStart="true" android:layout_alignParentStart="true"
android:layout_alignParentLeft="true" android:layout_alignParentLeft="true"
android:layout_centerVertical="true" android:layout_centerVertical="true"
android:layout_toStartOf="@id/sendButton" android:layout_toStartOf="@id/attachmentButton"
android:layout_toLeftOf="@id/sendButton" android:layout_toLeftOf="@id/attachmentButton"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:gravity="center_vertical" android:gravity="center_vertical"
android:hint="@string/room_message_placeholder_not_encrypted" android:hint="@string/room_message_placeholder_not_encrypted"

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->

<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:minHeight="48dp"
android:paddingLeft="8dp"
android:paddingRight="8dp">

<!-- Do not use drawableStart for icon size and for RTL -->
<ImageView
android:id="@+id/adapter_item_dialog_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:rotationY="@integer/rtl_mirror_flip"
android:tint="?attr/colorAccent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/video_call_green" />

<TextView
android:id="@+id/adapter_item_dialog_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/adapter_item_dialog_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="@string/action_video_call" />

</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -59,10 +59,27 @@
android:layout_marginEnd="32dp" android:layout_marginEnd="32dp"
android:layout_marginRight="32dp" android:layout_marginRight="32dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0" app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageMemberNameView" /> app:layout_constraintTop_toBottomOf="@+id/messageMemberNameView" />


<include
android:id="@+id/messageImageUploadProgressLayout"
layout="@layout/media_upload_download_progress_layout"
android:layout_width="0dp"
android:layout_height="46dp"
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="32dp"
android:layout_marginRight="32dp"
android:layout_marginBottom="8dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageImageView"
tools:visibility="visible" />


</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<TextView
android:id="@+id/mediaProgressTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textSize="12sp"
tools:text="Information" />

<ProgressBar
android:id="@+id/mediaProgressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_gravity="center_vertical"
android:max="100"
android:min="0"
android:progress="0"
tools:progress="45" />

</LinearLayout>

View File

@ -114,7 +114,8 @@
<item name="vctr_tabbar_background">@drawable/vector_tabbar_background_light</item> <item name="vctr_tabbar_background">@drawable/vector_tabbar_background_light</item>


<item name="vctr_pill_background_user_id">@drawable/pill_background_user_id_light</item> <item name="vctr_pill_background_user_id">@drawable/pill_background_user_id_light</item>
<item name="vctr_pill_background_room_alias">@drawable/pill_background_room_alias_light</item> <item name="vctr_pill_background_room_alias">@drawable/pill_background_room_alias_light
</item>


<item name="vctr_pill_text_color_user_id">@color/riot_primary_text_color_light</item> <item name="vctr_pill_text_color_user_id">@color/riot_primary_text_color_light</item>
<item name="vctr_pill_text_color_room_alias">@android:color/white</item> <item name="vctr_pill_text_color_room_alias">@android:color/white</item>
@ -261,6 +262,11 @@
<item name="actionBarTabStyle">@style/Vector.TabView.Group</item> <item name="actionBarTabStyle">@style/Vector.TabView.Group</item>
</style> </style>


<style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat">
<item name="titleTextColor">?attr/actionMenuTextColor</item>
<item name="android:background">?attr/colorPrimary</item>
</style>

<style name="AppTheme.Dialog.Light" parent="Theme.AppCompat.Light.Dialog.Alert" /> <style name="AppTheme.Dialog.Light" parent="Theme.AppCompat.Light.Dialog.Alert" />


</resources> </resources>