Merge branch 'feature/media_attachment' into develop
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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>()
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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() {
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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() })
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -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 ***************************************************************************
|
||||
|
|
|
@ -29,7 +29,8 @@ internal class TimelineEventFactory(private val roomMemberExtractor: RoomMemberE
|
|||
eventEntity.asDomain(),
|
||||
eventEntity.localId,
|
||||
eventEntity.displayIndex,
|
||||
roomMember
|
||||
roomMember,
|
||||
eventEntity.sendState
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)}"
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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> {
|
||||
|
|
BIN
vector/src/main/res/drawable-hdpi/ic_attach_file_white.png
Normal file
After Width: | Height: | Size: 394 B |
BIN
vector/src/main/res/drawable-mdpi/ic_attach_file_white.png
Normal file
After Width: | Height: | Size: 285 B |
BIN
vector/src/main/res/drawable-mdpi/ic_material_camera.png
Executable file
After Width: | Height: | Size: 539 B |
BIN
vector/src/main/res/drawable-mdpi/ic_material_file.png
Executable file
After Width: | Height: | Size: 545 B |
BIN
vector/src/main/res/drawable-mdpi/ic_material_videocam.png
Executable file
After Width: | Height: | Size: 3 KiB |
BIN
vector/src/main/res/drawable-mdpi/ic_send_sticker.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
vector/src/main/res/drawable-mdpi/vector_cancel_upload_download.png
Executable file
After Width: | Height: | Size: 309 B |
BIN
vector/src/main/res/drawable-mdpi/vector_micro_green.png
Executable file
After Width: | Height: | Size: 399 B |
BIN
vector/src/main/res/drawable-mdpi/video_call_black.png
Executable file
After Width: | Height: | Size: 224 B |
BIN
vector/src/main/res/drawable-mdpi/video_call_green.png
Executable file
After Width: | Height: | Size: 298 B |
BIN
vector/src/main/res/drawable-mdpi/voice_call_black.png
Executable file
After Width: | Height: | Size: 574 B |
BIN
vector/src/main/res/drawable-mdpi/voice_call_end_fushia.png
Executable file
After Width: | Height: | Size: 331 B |
BIN
vector/src/main/res/drawable-mdpi/voice_call_green.png
Executable file
After Width: | Height: | Size: 432 B |
BIN
vector/src/main/res/drawable-mdpi/voice_call_start_green.png
Executable file
After Width: | Height: | Size: 684 B |
BIN
vector/src/main/res/drawable-xhdpi/ic_attach_file_white.png
Normal file
After Width: | Height: | Size: 507 B |
BIN
vector/src/main/res/drawable-xxhdpi/ic_attach_file_white.png
Normal file
After Width: | Height: | Size: 809 B |
BIN
vector/src/main/res/drawable-xxxhdpi/ic_attach_file_white.png
Normal file
After Width: | Height: | Size: 1 KiB |
62
vector/src/main/res/layout/dialog_export_e2e_keys.xml
Normal 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>
|
|
@ -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"
|
||||
|
|
53
vector/src/main/res/layout/item_dialog.xml
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|