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: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'realm-android'
apply plugin: 'okreplay'
@ -19,6 +20,10 @@ repositories {
jcenter()
}
androidExtensions {
experimental = true
}
android {
compileSdkVersion 28
testOptions.unitTests.includeAndroidResources = true

View file

@ -18,6 +18,7 @@ package im.vector.matrix.android.api.session
import androidx.annotation.MainThread
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.crypto.CryptoService
import im.vector.matrix.android.api.session.group.GroupService
@ -58,6 +59,11 @@ interface Session :
*/
fun contentUrlResolver(): ContentUrlResolver
/**
* Returns the ContentUploadProgressTracker associated with the session
*/
fun contentUploadProgressTracker(): ContentUploadStateTracker
/**
* Add a listener to the session.
* @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)
data class VideoInfo(
@Json(name = "mimetype") val mimeType: String,
@Json(name = "w") val w: Int = 0,
@Json(name = "h") val h: Int = 0,
@Json(name = "w") val width: Int = 0,
@Json(name = "h") val height: Int = 0,
@Json(name = "size") val size: Long = 0,
@Json(name = "duration") val duration: Int = 0,
@Json(name = "thumbnail_info") val thumbnailInfo: ThumbnailInfo? = null,

View file

@ -16,11 +16,11 @@
package im.vector.matrix.android.api.session.room.send
import im.vector.matrix.android.api.MatrixCallback
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.util.Cancelable
/**
* 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.
* @param text the text message to send
* @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE
* @param callback the callback to be notified.
* @return a [Cancelable]
*/
fun sendTextMessage(text: String,
msgType: String = MessageType.MSGTYPE_TEXT,
callback: MatrixCallback<Event>): Cancelable
fun sendTextMessage(text: String, msgType: String = MessageType.MSGTYPE_TEXT): 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.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.
@ -28,7 +29,8 @@ data class TimelineEvent(
val root: Event,
val localId: String,
val displayIndex: Int,
val roomMember: RoomMember?
val roomMember: RoomMember?,
val sendState: SendState
) {
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.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.toEntity
import im.vector.matrix.android.internal.database.model.ChunkEntity
@ -89,9 +90,10 @@ internal fun ChunkEntity.add(roomId: String,
isUnlinked: Boolean = false) {
assertIsManaged()
if (event.eventId.isNullOrEmpty() || events.fastContains(event.eventId)) {
if (event.eventId.isNullOrEmpty() || this.events.fastContains(event.eventId)) {
return
}
var currentDisplayIndex = lastDisplayIndex(direction, 0)
if (direction == PaginationDirection.FORWARDS) {
currentDisplayIndex += 1
@ -115,6 +117,7 @@ internal fun ChunkEntity.add(roomId: String,
this.stateIndex = currentStateIndex
this.isUnlinked = isUnlinked
this.displayIndex = currentDisplayIndex
this.sendState = SendState.SYNCED
}
// We are not using the order of the list, but will be sorting with displayIndex field
events.add(eventEntity)
@ -122,14 +125,14 @@ internal fun ChunkEntity.add(roomId: String,
internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) {
PaginationDirection.FORWARDS -> forwardsDisplayIndex
PaginationDirection.BACKWARDS -> backwardsDisplayIndex
} ?: defaultValue
PaginationDirection.FORWARDS -> forwardsDisplayIndex
PaginationDirection.BACKWARDS -> backwardsDisplayIndex
} ?: defaultValue
}
internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) {
PaginationDirection.FORWARDS -> forwardsStateIndex
PaginationDirection.BACKWARDS -> backwardsStateIndex
} ?: defaultValue
PaginationDirection.FORWARDS -> forwardsStateIndex
PaginationDirection.BACKWARDS -> backwardsStateIndex
} ?: defaultValue
}

View file

@ -17,6 +17,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.room.send.SendState
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.RoomEntity
@ -49,6 +50,16 @@ internal fun RoomEntity.addStateEvents(stateEvents: List<Event>,
this.stateIndex = stateIndex
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
import im.vector.matrix.android.api.session.room.send.SendState
import io.realm.RealmObject
import io.realm.RealmResults
import io.realm.annotations.Ignore
import io.realm.annotations.Index
import io.realm.annotations.LinkingObjects
import io.realm.annotations.PrimaryKey
import java.util.*
import kotlin.properties.Delegates
internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUID().toString(),
@Index var eventId: String = "",
@ -45,6 +48,13 @@ internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUI
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
@LinkingObjects("events")

View file

@ -26,6 +26,7 @@ import kotlin.properties.Delegates
internal open class RoomEntity(@PrimaryKey var roomId: String = "",
var chunks: RealmList<ChunkEntity> = RealmList(),
var untimelinedStateEvents: RealmList<EventEntity> = RealmList(),
var sendingTimelineEvents: RealmList<EventEntity> = RealmList(),
var areAllMembersLoaded: Boolean = false
) : 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.auth.data.SessionParams
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.group.Group
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.di.MatrixKoinComponent
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.room.RoomModule
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 syncThread by inject<SyncThread>()
private val contentUrlResolver by inject<ContentUrlResolver>()
private val contentUploadProgressTracker by inject<ContentUploadStateTracker>()
private var isOpen = false
@MainThread
@ -77,7 +80,8 @@ internal class DefaultSession(override val sessionParams: SessionParams) : Sessi
val groupModule = GroupModule().definition
val signOutModule = SignOutModule().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)
if (!monarchy.isMonarchyThreadOpen) {
monarchy.openManually()
@ -121,6 +125,10 @@ internal class DefaultSession(override val sessionParams: SessionParams) : Sessi
return contentUrlResolver
}
override fun contentUploadProgressTracker(): ContentUploadStateTracker {
return contentUploadProgressTracker
}
override fun addListener(listener: Session.Listener) {
sessionListeners.addListener(listener)
}

View file

@ -19,13 +19,11 @@ package im.vector.matrix.android.internal.session
import android.content.Context
import com.zhuinden.monarchy.Monarchy
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.room.RoomService
import im.vector.matrix.android.api.session.signout.SignOutService
import im.vector.matrix.android.api.session.user.UserService
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.GroupSummaryUpdater
import im.vector.matrix.android.internal.session.room.DefaultRoomService
@ -116,10 +114,6 @@ internal class SessionModule(private val sessionParams: SessionParams) {
SessionListeners()
}
scope(DefaultSession.SCOPE) {
DefaultContentUrlResolver(sessionParams.homeServerConnectionConfig) as ContentUrlResolver
}
scope(DefaultSession.SCOPE) {
val groupSummaryUpdater = GroupSummaryUpdater(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 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 {

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 com.zhuinden.monarchy.Monarchy
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.query.where
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.SetReadMarkersTask
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.SendStateTask
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 contextOfEventTask: GetContextOfEventTask,
private val setReadMarkersTask: SetReadMarkersTask,
private val eventFactory: EventFactory,
private val eventFactory: LocalEchoEventFactory,
private val taskExecutor: TaskExecutor) {
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.read.DefaultSetReadMarkersTask
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.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 retrofit2.Retrofit
@ -63,7 +67,7 @@ class RoomModule {
}
scope(DefaultSession.SCOPE) {
EventFactory(get())
LocalEchoEventFactory(get())
}
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 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 workData = WorkerParamsFactory.toData(pruneEventWorkerParams)

View file

@ -21,7 +21,6 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
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.EventType
import im.vector.matrix.android.internal.database.mapper.ContentMapper
import im.vector.matrix.android.internal.database.model.EventEntity
@ -37,8 +36,8 @@ internal class PruneEventWorker(context: Context,
) : Worker(context, workerParameters), MatrixKoinComponent {
@JsonClass(generateAdapter = true)
internal data class Params(
val redactionEvents: List<Event>
internal class Params(
val eventIdsToRedact: List<String>
)
private val monarchy by inject<Monarchy>()
@ -48,18 +47,19 @@ internal class PruneEventWorker(context: Context,
?: return Result.failure()
val result = monarchy.tryTransactionSync { realm ->
params.redactionEvents.forEach { event ->
pruneEvent(realm, event)
params.eventIdsToRedact.forEach { eventId ->
pruneEvent(realm, eventId)
}
}
return result.fold({ Result.retry() }, { Result.success() })
}
private fun pruneEvent(realm: Realm, redactionEvent: Event?) {
if (redactionEvent == null || redactionEvent.redacts.isNullOrEmpty()) {
private fun pruneEvent(realm: Realm, eventIdToRedact: String) {
if (eventIdToRedact.isEmpty()) {
return
}
val eventToPrune = EventEntity.where(realm, eventId = redactionEvent.redacts).findFirst()
val eventToPrune = EventEntity.where(realm, eventId = eventIdToRedact).findFirst()
?: return
val allowedKeys = computeAllowedKeys(eventToPrune.type)
@ -87,7 +87,7 @@ internal class PruneEventWorker(context: Context,
EventType.STATE_ROOM_ALIASES -> listOf("aliases")
EventType.STATE_CANONICAL_ALIAS -> listOf("alias")
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
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 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.send.SendService
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.RoomEntity
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.WorkerParamsFactory
import im.vector.matrix.android.internal.util.tryTransactionAsync
import java.util.concurrent.TimeUnit
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,
private val eventFactory: EventFactory,
private val monarchy: Monarchy) : SendService {
private val eventFactory: LocalEchoEventFactory,
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, callback: MatrixCallback<Event>): Cancelable {
val event = eventFactory.createTextEvent(roomId, msgType, text)
monarchy.tryTransactionAsync { realm ->
val chunkEntity = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId)
?: return@tryTransactionAsync
chunkEntity.add(roomId, event, PaginationDirection.FORWARDS)
override fun sendTextMessage(text: String, msgType: String): Cancelable {
val event = eventFactory.createTextEvent(roomId, msgType, text).also {
saveLocalEcho(it)
}
val sendWork = createSendEventWork(event)
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(SEND_WORK), ExistingWorkPolicy.APPEND, sendWork)
.enqueue()
return CancelableWork(sendWork.id)
}
val sendContentWorkerParams = SendEventWorker.Params(roomId, event)
val workData = WorkerParamsFactory.toData(sendContentWorkerParams)
override fun sendMedias(attachments: List<ContentAttachmentData>): Cancelable {
val cancelableBag = CancelableBag()
attachments.forEach {
sendMedia(it).addTo(cancelableBag)
}
return cancelableBag
}
val sendWork = OneTimeWorkRequestBuilder<SendEventWorker>()
.setConstraints(sendConstraints)
.setInputData(workData)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10_000, TimeUnit.MILLISECONDS)
.build()
override fun sendMedia(attachment: ContentAttachmentData): Cancelable {
// Create an event with the media file path
val event = eventFactory.createMediaEvent(roomId, attachment).also {
saveLocalEcho(it)
}
val uploadWork = createUploadMediaWork(event, attachment)
val sendWork = createSendEventWork(event)
WorkManager.getInstance()
.beginUniqueWork(SEND_WORK, ExistingWorkPolicy.APPEND, sendWork)
.beginUniqueWork(buildWorkIdentifier(UPLOAD_WORK), ExistingWorkPolicy.APPEND, uploadWork)
.then(sendWork)
.enqueue()
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.WorkerParameters
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.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.network.executeRequest
import im.vector.matrix.android.internal.session.room.RoomAPI
import im.vector.matrix.android.internal.util.WorkerParamsFactory
import im.vector.matrix.android.internal.util.tryTransactionSync
import org.koin.standalone.inject
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 monarchy by inject<Monarchy>()
override fun doWork(): Result {
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.failure()
if (params.event.eventId == null) {
val localEvent = params.event
if (localEvent.eventId == null) {
return Result.failure()
}
val result = executeRequest<SendResponse> {
apiCall = roomAPI.send(
params.event.eventId,
localEvent.eventId,
params.roomId,
params.event.type,
params.event.content
localEvent.type,
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() })
}
}

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.util.CancelableBag
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.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.model.*
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.where
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.Sort
import im.vector.matrix.android.internal.util.Debouncer
import io.realm.*
import timber.log.Timber
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlin.collections.ArrayList
private const val INITIAL_LOAD_SIZE = 20
@ -68,8 +62,7 @@ internal class DefaultTimeline(
set(value) {
field = value
backgroundHandler.get()?.post {
val snapshot = snapshot()
mainHandler.post { listener?.onUpdated(snapshot) }
postSnapshot()
}
}
@ -80,41 +73,46 @@ internal class DefaultTimeline(
private val mainHandler = Handler(Looper.getMainLooper())
private val backgroundRealm = AtomicReference<Realm>()
private val cancelableBag = CancelableBag()
private val debouncer = Debouncer(mainHandler)
private lateinit var liveEvents: RealmResults<EventEntity>
private var roomEntity: RoomEntity? = null
private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN
private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN
private val isLive = initialEventId == null
private val builtEvents = Collections.synchronizedList<TimelineEvent>(ArrayList())
private val backwardsPaginationState = AtomicReference(PaginationState())
private val forwardsPaginationState = AtomicReference(PaginationState())
private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<EventEntity>> { _, changeSet ->
// TODO HANDLE CHANGES
changeSet.insertionRanges.forEach { range ->
val (startDisplayIndex, direction) = if (range.startIndex == 0) {
Pair(liveEvents[range.length - 1]!!.displayIndex, Timeline.Direction.FORWARDS)
} else {
Pair(liveEvents[range.startIndex]!!.displayIndex, Timeline.Direction.BACKWARDS)
}
val state = getPaginationState(direction)
if (state.isPaginating) {
// We are getting new items from pagination
val shouldPostSnapshot = paginateInternal(startDisplayIndex, direction, state.requestedCount)
if (shouldPostSnapshot) {
if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL) {
handleInitialLoad()
} else {
changeSet.insertionRanges.forEach { range ->
val (startDisplayIndex, direction) = if (range.startIndex == 0) {
Pair(liveEvents[range.length - 1]!!.displayIndex, Timeline.Direction.FORWARDS)
} else {
Pair(liveEvents[range.startIndex]!!.displayIndex, Timeline.Direction.BACKWARDS)
}
val state = getPaginationState(direction)
if (state.isPaginating) {
// 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()
}
} 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) {
backgroundHandler.get()?.post {
@ -142,12 +140,19 @@ internal class DefaultTimeline(
val realm = Realm.getInstance(realmConfiguration)
backgroundRealm.set(realm)
clearUnlinkedEvents(realm)
isReady.set(true)
roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()?.also {
it.sendingTimelineEvents.addChangeListener { _ ->
postSnapshot()
}
}
liveEvents = buildEventQuery(realm)
.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
.findAll()
.findAllAsync()
.also { it.addChangeListener(eventsChangeListener) }
handleInitialLoad()
isReady.set(true)
}
}
}
@ -171,7 +176,7 @@ internal class DefaultTimeline(
return hasMoreInCache(direction) || !hasReachedEnd(direction)
}
// Private methods *****************************************************************************
// Private methods *****************************************************************************
private fun hasMoreInCache(direction: Timeline.Direction): Boolean {
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
* @return true if snapshot should be posted
* @return true if createSnapshot should be posted
*/
private fun paginateInternal(startDisplayIndex: Int,
direction: Timeline.Direction,
@ -222,8 +227,19 @@ internal class DefaultTimeline(
return !shouldFetchMore
}
private fun snapshot(): List<TimelineEvent> {
return builtEvents.toList()
private fun createSnapshot(): List<TimelineEvent> {
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 {
@ -282,9 +298,9 @@ internal class DefaultTimeline(
private fun executePaginationTask(direction: Timeline.Direction, limit: Int) {
val token = getTokenLive(direction) ?: return
val params = PaginationTask.Params(roomId = roomId,
from = token,
direction = direction.toPaginationDirection(),
limit = limit)
from = token,
direction = direction.toPaginationDirection(),
limit = limit)
Timber.v("Should fetch $limit items $direction")
paginationTask.configureWith(params)
@ -414,8 +430,9 @@ internal class DefaultTimeline(
}
private fun postSnapshot() {
val snapshot = snapshot()
mainHandler.post { listener?.onUpdated(snapshot) }
val snapshot = createSnapshot()
val runnable = Runnable { listener?.onUpdated(snapshot) }
debouncer.debounce("post_snapshot", runnable, 50)
}
// Extension methods ***************************************************************************

View file

@ -29,7 +29,8 @@ internal class TimelineEventFactory(private val roomMemberExtractor: RoomMemberE
eventEntity.asDomain(),
eventEntity.localId,
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.model.ChunkEntity
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.where
import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater
@ -108,6 +109,15 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
timelineStateOffset
)
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)
@ -161,7 +171,6 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
} else {
realm.createObject<ChunkEntity>().apply { this.prevToken = prevToken }
}
lastChunk?.isLastForward = false
chunkEntity.isLastForward = true
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 markwon_version = '3.0.0-SNAPSHOT'
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-rx")
@ -191,6 +191,9 @@ dependencies {
// Badge for compatibility
implementation 'me.leolin:ShortcutBadger:1.1.2@aar'
// File picker
implementation 'com.github.jaiselrahman:FilePicker:1.2.2'
// DI
implementation "org.koin:koin-android:$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
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.TimelineEvent
sealed class RoomDetailActions {
data class SendMessage(val text: String) : RoomDetailActions()
data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions()
object IsDisplayed : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions()

View file

@ -16,6 +16,8 @@
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.drawable.ColorDrawable
import android.os.Bundle
@ -28,6 +30,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker
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.AutocompleteCallback
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.user.model.User
import im.vector.riotredesign.R
import im.vector.riotredesign.core.dialogs.DialogListItem
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotredesign.core.extensions.observeEvent
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.core.platform.ToolbarConfigurable
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.CommandAutocompletePolicy
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.getOrCreateScope
import org.koin.core.parameter.parametersOf
import timber.log.Timber
@Parcelize
@ -70,6 +83,10 @@ data class RoomDetailArgs(
) : 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 {
companion object {
@ -103,17 +120,28 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
setupRecyclerView()
setupToolbar()
setupComposer()
setupAttachmentButton()
roomDetailViewModel.subscribe { renderState(it) }
textComposerViewModel.subscribe { renderTextComposerState(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() {
super.onResume()
roomDetailViewModel.process(RoomDetailActions.IsDisplayed)
}
// PRIVATE METHODS *****************************************************************************
// PRIVATE METHODS *****************************************************************************
private fun setupToolbar() {
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) {
renderRoomSummary(state)
timelineEventController.setTimeline(state.timeline)
@ -240,20 +339,20 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
when (sendMessageResult) {
is SendMessageResult.MessageSent,
is SendMessageResult.SlashCommandHandled -> {
is SendMessageResult.SlashCommandHandled -> {
// Clear composer
composerEditText.text = null
}
is SendMessageResult.SlashCommandError -> {
is SendMessageResult.SlashCommandError -> {
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))
}
is SendMessageResult.SlashCommandResultOk -> {
is SendMessageResult.SlashCommandResultOk -> {
// Ignore
}
is SendMessageResult.SlashCommandResultError -> {
is SendMessageResult.SlashCommandResultError -> {
displayCommandError(sendMessageResult.throwable.localizedMessage)
}
is SendMessageResult.SlashCommandNotImplemented -> {
@ -270,7 +369,7 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
.show()
}
// TimelineEventController.Callback ************************************************************
// TimelineEventController.Callback ************************************************************
override fun onUrlClicked(url: String) {
homePermalinkHandler.launch(url)
@ -285,7 +384,7 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
startActivity(intent)
}
// AutocompleteUserPresenter.Callback
// AutocompleteUserPresenter.Callback
override fun onQueryUsers(query: CharSequence?) {
textComposerViewModel.process(TextComposerActions.QueryUsers(query))

View file

@ -23,7 +23,7 @@ import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
import im.vector.matrix.android.api.MatrixCallback
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.rx.rx
import im.vector.riotredesign.core.platform.VectorViewModel
@ -69,10 +69,11 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
fun process(action: RoomDetailActions) {
when (action) {
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.SendMedia -> handleSendMedia(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)
when (slashCommandResult) {
is ParsedCommand.ErrorNotACommand -> {
is ParsedCommand.ErrorNotACommand -> {
// Send the text message to the room
room.sendTextMessage(action.text, callback = object : MatrixCallback<Event> {})
room.sendTextMessage(action.text)
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
is ParsedCommand.ErrorSyntax -> {
is ParsedCommand.ErrorSyntax -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command)))
}
is ParsedCommand.ErrorEmptySlashCommand -> {
is ParsedCommand.ErrorEmptySlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown("/")))
}
is ParsedCommand.ErrorUnknownSlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown(slashCommandResult.slashCommand)))
}
is ParsedCommand.Invite -> {
is ParsedCommand.Invite -> {
handleInviteSlashCommand(slashCommandResult)
}
is ParsedCommand.SetUserPowerLevel -> {
is ParsedCommand.SetUserPowerLevel -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.ClearScalarToken -> {
is ParsedCommand.ClearScalarToken -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.SetMarkdown -> {
is ParsedCommand.SetMarkdown -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.UnbanUser -> {
is ParsedCommand.UnbanUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.BanUser -> {
is ParsedCommand.BanUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.KickUser -> {
is ParsedCommand.KickUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.JoinRoom -> {
is ParsedCommand.JoinRoom -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.PartRoom -> {
is ParsedCommand.PartRoom -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.SendEmote -> {
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE, callback = object : MatrixCallback<Event> {})
is ParsedCommand.SendEmote -> {
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled))
}
is ParsedCommand.ChangeTopic -> {
is ParsedCommand.ChangeTopic -> {
handleChangeTopicSlashCommand(slashCommandResult)
}
is ParsedCommand.ChangeDisplayName -> {
is ParsedCommand.ChangeDisplayName -> {
// TODO
_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) {
displayedEventsObservable.accept(action)
}
@ -216,4 +234,5 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
timeline.dispose()
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.session.events.model.EventType
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.riotredesign.R
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.helper.TimelineDateFormatter
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.media.MediaContentRenderer
import me.gujun.android.span.span
@ -47,6 +58,7 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
callback: TimelineEventController.Callback?
): VectorEpoxyModel<*>? {
val eventId = event.root.eventId ?: return null
val roomMember = event.roomMember
val nextRoomMember = nextEvent?.roomMember
@ -54,12 +66,12 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60))
?: false
?: false
val showInformation = addDaySeparator
|| nextRoomMember != roomMember
|| nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo
|| nextRoomMember != roomMember
|| nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo
val messageContent: MessageContent = event.root.content.toModel() ?: return null
val time = timelineDateFormatter.formatMessageHour(date)
@ -68,11 +80,12 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
val informationData = MessageInformationData(time, avatarUrl, memberName, showInformation)
return when (messageContent) {
is MessageTextContent -> buildTextMessageItem(messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageTextContent -> buildTextMessageItem(event.sendState, messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(eventId, messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(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)
}
private fun buildImageMessageItem(messageContent: MessageImageContent,
private fun buildImageMessageItem(eventId: String,
messageContent: MessageImageContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageImageItem? {
@ -97,22 +111,30 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
orientation = messageContent.info?.orientation
)
return MessageImageItem_()
.eventId(eventId)
.informationData(informationData)
.mediaData(data)
.clickListener { view -> callback?.onMediaClicked(data, view) }
}
private fun buildTextMessageItem(messageContent: MessageTextContent,
private fun buildTextMessageItem(sendState: SendState,
messageContent: MessageTextContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageTextItem? {
val bodyToUse = messageContent.formattedBody
?.let {
htmlRenderer.render(it)
}
?: messageContent.body
val bodyToUse = messageContent.formattedBody?.let {
htmlRenderer.render(it)
} ?: 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_()
.message(linkifiedBody)
.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
override fun bind(holder: H) {
super.bind(holder)
if (informationData.showInformation) {
holder.avatarImageView.visibility = View.VISIBLE
holder.memberNameView.visibility = View.VISIBLE

View file

@ -17,30 +17,42 @@
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.riotredesign.features.media.MediaContentRenderer
@EpoxyModelClass(layout = R.layout.item_timeline_event_image_message)
abstract class MessageImageItem : AbsMessageItem<MessageImageItem.Holder>() {
@EpoxyAttribute lateinit var mediaData: MediaContentRenderer.Data
@EpoxyAttribute lateinit var eventId: String
@EpoxyAttribute override lateinit var informationData: MessageInformationData
@EpoxyAttribute var clickListener: View.OnClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
MediaContentRenderer.render(mediaData, MediaContentRenderer.Mode.THUMBNAIL, holder.imageView)
ContentUploadStateTrackerBinder.bind(eventId, mediaData, holder.progressLayout)
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() {
override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
override val memberNameView by bind<TextView>(R.id.messageMemberNameView)
override val timeView by bind<TextView>(R.id.messageTimeView)
val progressLayout by bind<ViewGroup>(R.id.messageImageUploadProgressLayout)
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.riotredesign.core.glide.GlideApp
import kotlinx.android.parcel.Parcelize
import java.io.File
object MediaContentRenderer {
@ -38,7 +39,12 @@ object MediaContentRenderer {
val maxWidth: Int,
val orientation: Int?,
val rotation: Int?
) : Parcelable
) : Parcelable {
fun isLocalFile(): Boolean {
return url != null && File(url).exists()
}
}
enum class Mode {
FULL_SIZE,
@ -51,9 +57,11 @@ object MediaContentRenderer {
imageView.layoutParams.width = width
val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver()
val resolvedUrl = when (mode) {
Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url)
Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
} ?: return
Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url)
Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
}
//Fallback to base url
?: data.url
GlideApp
.with(imageView)
@ -65,12 +73,16 @@ object MediaContentRenderer {
fun render(data: Data, imageView: BigImageView) {
val (width, height) = processSize(data, Mode.THUMBNAIL)
val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver()
val fullSize = contentUrlResolver.resolveFullSize(data.url)
val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
imageView.showImage(
Uri.parse(thumbnail),
Uri.parse(fullSize)
)
if (data.isLocalFile()) {
imageView.showImage(Uri.parse(data.url))
} else {
val fullSize = contentUrlResolver.resolveFullSize(data.url)
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> {

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 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 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_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
android:id="@+id/sendButton"
android:layout_width="48dp"
@ -115,8 +126,8 @@
android:layout_alignParentStart="true"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/sendButton"
android:layout_toLeftOf="@id/sendButton"
android:layout_toStartOf="@id/attachmentButton"
android:layout_toLeftOf="@id/attachmentButton"
android:background="@android:color/transparent"
android:gravity="center_vertical"
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_marginRight="32dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageMemberNameView" />
<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>

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_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_room_alias">@android:color/white</item>
@ -261,6 +262,11 @@
<item name="actionBarTabStyle">@style/Vector.TabView.Group</item>
</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" />
</resources>