Merge pull request #1010 from vector-im/feature/attachment_process

Attachment process
This commit is contained in:
Benoit Marty 2020-02-18 09:19:17 +01:00 committed by GitHub
commit 8d1b2b35fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 2145 additions and 428 deletions

View file

@ -3,7 +3,10 @@ Changes in RiotX 0.17.0 (2020-XX-XX)
Features ✨:
- Secured Shared Storage Support (#984, #936)
- Polls and Bot Buttons (MSC 2192 matrix-org/matrix-doc#2192)
- It's now possible to select several rooms (with a possible mix of clear/encrypted rooms) when sharing elements to RiotX (#1010)
- Media preview: media are previewed before being sent to a room (#1010)
- Image edition: it's now possible to edit image before sending: crop, rotate, and delete actions are supported (#1010)
- Sending image: image are sent to rooms with a reduced size. It's still possible to send original image file (#1010)
Improvements 🙌:
-

View file

@ -34,6 +34,9 @@ allprojects {
includeGroupByRegex "com\\.github\\.jaiselrahman"
// And monarchy
includeGroupByRegex "com\\.github\\.Zhuinden"
// And ucrop
includeGroupByRegex "com\\.github\\.yalantis"
// JsonViewer
includeGroupByRegex 'com\\.github\\.BillCarsonFr'
}
}

View file

@ -119,6 +119,7 @@ dependencies {
// Image
implementation 'androidx.exifinterface:exifinterface:1.1.0'
implementation 'id.zelory:compressor:3.0.0'
// Database
implementation 'com.github.Zhuinden:realm-monarchy:0.5.1'

View file

@ -29,6 +29,7 @@ data class ContentAttachmentData(
val width: Long? = 0,
val exifOrientation: Int = ExifInterface.ORIENTATION_UNDEFINED,
val name: String? = null,
val queryUri: String,
val path: String,
val mimeType: String?,
val type: Type

View file

@ -51,16 +51,26 @@ interface SendService {
/**
* Method to send a media asynchronously.
* @param attachment the media to send
* @param compressBeforeSending set to true to compress images before sending them
* @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present.
* It can be useful to send media to multiple room. It's safe to include the current roomId in this set
* @return a [Cancelable]
*/
fun sendMedia(attachment: ContentAttachmentData): Cancelable
fun sendMedia(attachment: ContentAttachmentData,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable
/**
* Method to send a list of media asynchronously.
* @param attachments the list of media to send
* @param compressBeforeSending set to true to compress images before sending them
* @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present.
* It can be useful to send media to multiple room. It's safe to include the current roomId in this set
* @return a [Cancelable]
*/
fun sendMedias(attachments: List<ContentAttachmentData>): Cancelable
fun sendMedias(attachments: List<ContentAttachmentData>,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable
/**
* Send a poll to the room.

View file

@ -17,7 +17,11 @@
package im.vector.matrix.android.internal.di
import android.content.Context
import androidx.work.*
import androidx.work.Constraints
import androidx.work.ListenableWorker
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import javax.inject.Inject
internal class WorkManagerProvider @Inject constructor(
@ -54,5 +58,7 @@ internal class WorkManagerProvider @Inject constructor(
val workConstraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
const val BACKOFF_DELAY = 10_000L
}
}

View file

@ -38,6 +38,7 @@ import im.vector.matrix.android.internal.session.pushers.PushersModule
import im.vector.matrix.android.internal.session.room.RoomModule
import im.vector.matrix.android.internal.session.room.relation.SendRelationWorker
import im.vector.matrix.android.internal.session.room.send.EncryptEventWorker
import im.vector.matrix.android.internal.session.room.send.MultipleEventSendingDispatcherWorker
import im.vector.matrix.android.internal.session.room.send.RedactEventWorker
import im.vector.matrix.android.internal.session.room.send.SendEventWorker
import im.vector.matrix.android.internal.session.signout.SignOutModule
@ -85,23 +86,25 @@ internal interface SessionComponent {
fun taskExecutor(): TaskExecutor
fun inject(sendEventWorker: SendEventWorker)
fun inject(worker: SendEventWorker)
fun inject(sendEventWorker: SendRelationWorker)
fun inject(worker: SendRelationWorker)
fun inject(encryptEventWorker: EncryptEventWorker)
fun inject(worker: EncryptEventWorker)
fun inject(redactEventWorker: RedactEventWorker)
fun inject(worker: MultipleEventSendingDispatcherWorker)
fun inject(getGroupDataWorker: GetGroupDataWorker)
fun inject(worker: RedactEventWorker)
fun inject(uploadContentWorker: UploadContentWorker)
fun inject(worker: GetGroupDataWorker)
fun inject(syncWorker: SyncWorker)
fun inject(worker: UploadContentWorker)
fun inject(addHttpPusherWorker: AddHttpPusherWorker)
fun inject(worker: SyncWorker)
fun inject(sendVerificationMessageWorker: SendVerificationMessageWorker)
fun inject(worker: AddHttpPusherWorker)
fun inject(worker: SendVerificationMessageWorker)
@Component.Factory
interface Factory {

View file

@ -17,18 +17,25 @@
package im.vector.matrix.android.internal.session.content
import android.content.Context
import android.graphics.BitmapFactory
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
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.*
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.internal.crypto.attachments.MXEncryptedAttachments
import im.vector.matrix.android.internal.crypto.model.rest.EncryptedFileInfo
import im.vector.matrix.android.internal.network.ProgressRequestBody
import im.vector.matrix.android.internal.session.room.send.SendEventWorker
import im.vector.matrix.android.internal.session.room.send.MultipleEventSendingDispatcherWorker
import im.vector.matrix.android.internal.worker.SessionWorkerParams
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import im.vector.matrix.android.internal.worker.getSessionComponent
@ -38,15 +45,21 @@ import java.io.File
import java.io.FileInputStream
import javax.inject.Inject
internal class UploadContentWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
private data class NewImageAttributes(
val newWidth: Int?,
val newHeight: Int?,
val newFileSize: Int
)
internal class UploadContentWorker(val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
@JsonClass(generateAdapter = true)
internal data class Params(
override val sessionId: String,
val roomId: String,
val event: Event,
val events: List<Event>,
val attachment: ContentAttachmentData,
val isRoomEncrypted: Boolean,
val compressBeforeSending: Boolean,
override val lastFailureMessage: String? = null
) : SessionWorkerParams
@ -67,20 +80,50 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
val sessionComponent = getSessionComponent(params.sessionId) ?: return Result.success()
sessionComponent.inject(this)
val eventId = params.event.eventId ?: return Result.success()
val attachment = params.attachment
var newImageAttributes: NewImageAttributes? = null
val attachmentFile = try {
File(attachment.path)
} catch (e: Exception) {
Timber.e(e)
contentUploadStateTracker.setFailure(params.event.eventId, e)
notifyTracker(params) { contentUploadStateTracker.setFailure(it, e) }
return Result.success(
WorkerParamsFactory.toData(params.copy(
lastFailureMessage = e.localizedMessage
))
)
}
.let { originalFile ->
if (attachment.type == ContentAttachmentData.Type.IMAGE) {
if (params.compressBeforeSending) {
Compressor.compress(context, originalFile) {
default(
width = MAX_IMAGE_SIZE,
height = MAX_IMAGE_SIZE
)
}.also { compressedFile ->
// Update the params
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(compressedFile.absolutePath, options)
val fileSize = compressedFile.length().toInt()
newImageAttributes = NewImageAttributes(
options.outWidth,
options.outHeight,
fileSize
)
}
} else {
// TODO Fix here the image rotation issue
originalFile
}
} else {
// Other type
originalFile
}
}
var uploadedThumbnailUrl: String? = null
var uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo? = null
@ -88,14 +131,14 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
ThumbnailExtractor.extractThumbnail(params.attachment)?.let { thumbnailData ->
val thumbnailProgressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
contentUploadStateTracker.setProgressThumbnail(eventId, current, total)
notifyTracker(params) { contentUploadStateTracker.setProgressThumbnail(it, current, total) }
}
}
try {
val contentUploadResponse = if (params.isRoomEncrypted) {
Timber.v("Encrypt thumbnail")
contentUploadStateTracker.setEncryptingThumbnail(eventId)
notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(ByteArrayInputStream(thumbnailData.bytes), thumbnailData.mimeType)
uploadedThumbnailEncryptedFileInfo = encryptionResult.encryptedFileInfo
fileUploader.uploadByteArray(encryptionResult.encryptedByteArray,
@ -118,10 +161,12 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
val progressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
if (isStopped) {
contentUploadStateTracker.setFailure(eventId, Throwable("Cancelled"))
} else {
contentUploadStateTracker.setProgress(eventId, current, total)
notifyTracker(params) {
if (isStopped) {
contentUploadStateTracker.setFailure(it, Throwable("Cancelled"))
} else {
contentUploadStateTracker.setProgress(it, current, total)
}
}
}
}
@ -131,7 +176,7 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
return try {
val contentUploadResponse = if (params.isRoomEncrypted) {
Timber.v("Encrypt file")
contentUploadStateTracker.setEncrypting(eventId)
notifyTracker(params) { contentUploadStateTracker.setEncrypting(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(FileInputStream(attachmentFile), attachment.mimeType)
uploadedFileEncryptedFileInfo = encryptionResult.encryptedFileInfo
@ -143,7 +188,12 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
.uploadFile(attachmentFile, attachment.name, attachment.mimeType, progressListener)
}
handleSuccess(params, contentUploadResponse.contentUri, uploadedFileEncryptedFileInfo, uploadedThumbnailUrl, uploadedThumbnailEncryptedFileInfo)
handleSuccess(params,
contentUploadResponse.contentUri,
uploadedFileEncryptedFileInfo,
uploadedThumbnailUrl,
uploadedThumbnailEncryptedFileInfo,
newImageAttributes)
} catch (t: Throwable) {
Timber.e(t)
handleFailure(params, t)
@ -151,7 +201,8 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
}
private fun handleFailure(params: Params, failure: Throwable): Result {
contentUploadStateTracker.setFailure(params.event.eventId!!, failure)
notifyTracker(params) { contentUploadStateTracker.setFailure(it, failure) }
return Result.success(
WorkerParamsFactory.toData(
params.copy(
@ -165,11 +216,17 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
attachmentUrl: String,
encryptedFileInfo: EncryptedFileInfo?,
thumbnailUrl: String?,
thumbnailEncryptedFileInfo: EncryptedFileInfo?): Result {
thumbnailEncryptedFileInfo: EncryptedFileInfo?,
newImageAttributes: NewImageAttributes?): Result {
Timber.v("handleSuccess $attachmentUrl, work is stopped $isStopped")
contentUploadStateTracker.setSuccess(params.event.eventId!!)
val event = updateEvent(params.event, attachmentUrl, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo)
val sendParams = SendEventWorker.Params(params.sessionId, params.roomId, event)
notifyTracker(params) { contentUploadStateTracker.setSuccess(it) }
val updatedEvents = params.events
.map {
updateEvent(it, attachmentUrl, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo, newImageAttributes)
}
val sendParams = MultipleEventSendingDispatcherWorker.Params(params.sessionId, updatedEvents, params.isRoomEncrypted)
return Result.success(WorkerParamsFactory.toData(sendParams))
}
@ -177,10 +234,11 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
url: String,
encryptedFileInfo: EncryptedFileInfo?,
thumbnailUrl: String? = null,
thumbnailEncryptedFileInfo: EncryptedFileInfo?): Event {
thumbnailEncryptedFileInfo: EncryptedFileInfo?,
newImageAttributes: NewImageAttributes?): Event {
val messageContent: MessageContent = event.content.toModel() ?: return event
val updatedContent = when (messageContent) {
is MessageImageContent -> messageContent.update(url, encryptedFileInfo)
is MessageImageContent -> messageContent.update(url, encryptedFileInfo, newImageAttributes)
is MessageVideoContent -> messageContent.update(url, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo)
is MessageFileContent -> messageContent.update(url, encryptedFileInfo)
is MessageAudioContent -> messageContent.update(url, encryptedFileInfo)
@ -189,11 +247,23 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
return event.copy(content = updatedContent.toContent())
}
private fun notifyTracker(params: Params, function: (String) -> Unit) {
params.events
.mapNotNull { it.eventId }
.forEach { eventId -> function.invoke(eventId) }
}
private fun MessageImageContent.update(url: String,
encryptedFileInfo: EncryptedFileInfo?): MessageImageContent {
encryptedFileInfo: EncryptedFileInfo?,
newImageAttributes: NewImageAttributes?): MessageImageContent {
return copy(
url = if (encryptedFileInfo == null) url else null,
encryptedFileInfo = encryptedFileInfo?.copy(url = url)
encryptedFileInfo = encryptedFileInfo?.copy(url = url),
info = info?.copy(
width = newImageAttributes?.newWidth ?: info.width,
height = newImageAttributes?.newHeight ?: info.height,
size = newImageAttributes?.newFileSize ?: info.size
)
)
}
@ -226,4 +296,8 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
encryptedFileInfo = encryptedFileInfo?.copy(url = url)
)
}
companion object {
private const val MAX_IMAGE_SIZE = 640
}
}

View file

@ -196,13 +196,13 @@ internal class DefaultRelationService @AssistedInject constructor(
private fun createEncryptEventWork(event: Event, keepKeys: List<String>?): OneTimeWorkRequest {
// Same parameter
val params = EncryptEventWorker.Params(sessionId, roomId, event, keepKeys)
val params = EncryptEventWorker.Params(sessionId, event, keepKeys)
val sendWorkData = WorkerParamsFactory.toData(params)
return timeLineSendEventWorkCommon.createWork<EncryptEventWorker>(sendWorkData, true)
}
private fun createSendEventWork(event: Event, startChain: Boolean): OneTimeWorkRequest {
val sendContentWorkerParams = SendEventWorker.Params(sessionId, roomId, event)
val sendContentWorkerParams = SendEventWorker.Params(sessionId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)
return timeLineSendEventWorkCommon.createWork<SendEventWorker>(sendWorkData, startChain)
}

View file

@ -22,7 +22,6 @@ import androidx.work.OneTimeWorkRequest
import androidx.work.Operation
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.events.model.Event
@ -49,7 +48,6 @@ import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
private const val UPLOAD_WORK = "UPLOAD_WORK"
private const val BACKOFF_DELAY = 10_000L
internal class DefaultSendService @AssistedInject constructor(
@Assisted private val roomId: String,
@ -58,7 +56,6 @@ internal class DefaultSendService @AssistedInject constructor(
@SessionId private val sessionId: String,
private val localEchoEventFactory: LocalEchoEventFactory,
private val cryptoService: CryptoService,
private val monarchy: Monarchy,
private val taskExecutor: TaskExecutor,
private val localEchoRepository: LocalEchoRepository
) : SendService {
@ -103,6 +100,7 @@ internal class DefaultSendService @AssistedInject constructor(
return if (cryptoService.isRoomEncrypted(roomId)) {
Timber.v("Send event in encrypted room")
val encryptWork = createEncryptEventWork(event, true)
// Note that event will be replaced by the result of the previous work
val sendWork = createSendEventWork(event, false)
timelineSendEventWorkCommon.postSequentialWorks(roomId, encryptWork, sendWork)
} else {
@ -111,9 +109,11 @@ internal class DefaultSendService @AssistedInject constructor(
}
}
override fun sendMedias(attachments: List<ContentAttachmentData>): Cancelable {
override fun sendMedias(attachments: List<ContentAttachmentData>,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable {
return attachments.mapTo(CancelableBag()) {
sendMedia(it)
sendMedia(it, compressBeforeSending, roomIds)
}
}
@ -201,43 +201,56 @@ internal class DefaultSendService @AssistedInject constructor(
}
}
override fun sendMedia(attachment: ContentAttachmentData): Cancelable {
override fun sendMedia(attachment: ContentAttachmentData,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable {
// Create an event with the media file path
val event = localEchoEventFactory.createMediaEvent(roomId, attachment).also {
createLocalEcho(it)
// Ensure current roomId is included in the set
val allRoomIds = (roomIds + roomId).toList()
// Create local echo for each room
val allLocalEchoes = allRoomIds.map {
localEchoEventFactory.createMediaEvent(it, attachment).also { event ->
createLocalEcho(event)
}
}
return internalSendMedia(event, attachment)
return internalSendMedia(allLocalEchoes, attachment, compressBeforeSending)
}
private fun internalSendMedia(localEcho: Event, attachment: ContentAttachmentData): Cancelable {
val isRoomEncrypted = cryptoService.isRoomEncrypted(roomId)
/**
* We use the roomId of the local echo event
*/
private fun internalSendMedia(allLocalEchoes: List<Event>, attachment: ContentAttachmentData, compressBeforeSending: Boolean): Cancelable {
val cancelableBag = CancelableBag()
val uploadWork = createUploadMediaWork(localEcho, attachment, isRoomEncrypted, startChain = true)
val sendWork = createSendEventWork(localEcho, false)
allLocalEchoes.groupBy { cryptoService.isRoomEncrypted(it.roomId!!) }
.apply {
keys.forEach { isRoomEncrypted ->
// Should never be empty
val localEchoes = get(isRoomEncrypted).orEmpty()
val uploadWork = createUploadMediaWork(localEchoes, attachment, isRoomEncrypted, compressBeforeSending, startChain = true)
if (isRoomEncrypted) {
val encryptWork = createEncryptEventWork(localEcho, false /*not start of chain, take input error*/)
val dispatcherWork = createMultipleEventDispatcherWork(isRoomEncrypted)
val op: Operation = workManagerProvider.workManager
.beginUniqueWork(buildWorkName(UPLOAD_WORK), ExistingWorkPolicy.APPEND, uploadWork)
.then(encryptWork)
.then(sendWork)
.enqueue()
op.result.addListener(Runnable {
if (op.result.isCancelled) {
Timber.e("CHAIN WAS CANCELLED")
} else if (op.state.value is Operation.State.FAILURE) {
Timber.e("CHAIN DID FAIL")
workManagerProvider.workManager
.beginUniqueWork(buildWorkName(UPLOAD_WORK), ExistingWorkPolicy.APPEND, uploadWork)
.then(dispatcherWork)
.enqueue()
.also { operation ->
operation.result.addListener(Runnable {
if (operation.result.isCancelled) {
Timber.e("CHAIN WAS CANCELLED")
} else if (operation.state.value is Operation.State.FAILURE) {
Timber.e("CHAIN DID FAIL")
}
}, workerFutureListenerExecutor)
}
cancelableBag.add(CancelableWork(workManagerProvider.workManager, dispatcherWork.id))
}
}
}, workerFutureListenerExecutor)
} else {
workManagerProvider.workManager
.beginUniqueWork(buildWorkName(UPLOAD_WORK), ExistingWorkPolicy.APPEND, uploadWork)
.then(sendWork)
.enqueue()
}
return CancelableWork(workManagerProvider.workManager, sendWork.id)
return cancelableBag
}
private fun createLocalEcho(event: Event) {
@ -250,19 +263,19 @@ internal class DefaultSendService @AssistedInject constructor(
private fun createEncryptEventWork(event: Event, startChain: Boolean): OneTimeWorkRequest {
// Same parameter
val params = EncryptEventWorker.Params(sessionId, roomId, event)
val params = EncryptEventWorker.Params(sessionId, event)
val sendWorkData = WorkerParamsFactory.toData(params)
return workManagerProvider.matrixOneTimeWorkRequestBuilder<EncryptEventWorker>()
.setConstraints(WorkManagerProvider.workConstraints)
.setInputData(sendWorkData)
.startChain(startChain)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.setBackoffCriteria(BackoffPolicy.LINEAR, WorkManagerProvider.BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}
private fun createSendEventWork(event: Event, startChain: Boolean): OneTimeWorkRequest {
val sendContentWorkerParams = SendEventWorker.Params(sessionId, roomId, event)
val sendContentWorkerParams = SendEventWorker.Params(sessionId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)
return timelineSendEventWorkCommon.createWork<SendEventWorker>(sendWorkData, startChain)
@ -277,18 +290,33 @@ internal class DefaultSendService @AssistedInject constructor(
return timelineSendEventWorkCommon.createWork<RedactEventWorker>(redactWorkData, true)
}
private fun createUploadMediaWork(event: Event,
private fun createUploadMediaWork(allLocalEchos: List<Event>,
attachment: ContentAttachmentData,
isRoomEncrypted: Boolean,
compressBeforeSending: Boolean,
startChain: Boolean): OneTimeWorkRequest {
val uploadMediaWorkerParams = UploadContentWorker.Params(sessionId, roomId, event, attachment, isRoomEncrypted)
val uploadMediaWorkerParams = UploadContentWorker.Params(sessionId, allLocalEchos, attachment, isRoomEncrypted, compressBeforeSending)
val uploadWorkData = WorkerParamsFactory.toData(uploadMediaWorkerParams)
return workManagerProvider.matrixOneTimeWorkRequestBuilder<UploadContentWorker>()
.setConstraints(WorkManagerProvider.workConstraints)
.startChain(startChain)
.setInputData(uploadWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.setBackoffCriteria(BackoffPolicy.LINEAR, WorkManagerProvider.BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}
private fun createMultipleEventDispatcherWork(isRoomEncrypted: Boolean): OneTimeWorkRequest {
// the list of events will be replaced by the result of the media upload work
val params = MultipleEventSendingDispatcherWorker.Params(sessionId, emptyList(), isRoomEncrypted)
val workData = WorkerParamsFactory.toData(params)
return workManagerProvider.matrixOneTimeWorkRequestBuilder<MultipleEventSendingDispatcherWorker>()
// No constraint
// .setConstraints(WorkManagerProvider.workConstraints)
.startChain(false)
.setInputData(workData)
.setBackoffCriteria(BackoffPolicy.LINEAR, WorkManagerProvider.BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}
}

View file

@ -38,9 +38,8 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters)
@JsonClass(generateAdapter = true)
internal data class Params(
override val sessionId: String,
val roomId: String,
val event: Event,
/**Do not encrypt these keys, keep them as is in encrypted content (e.g. m.relates_to)*/
/** Do not encrypt these keys, keep them as is in encrypted content (e.g. m.relates_to) */
val keepKeys: List<String>? = null,
override val lastFailureMessage: String? = null
) : SessionWorkerParams
@ -52,7 +51,7 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters)
Timber.v("Start Encrypt work")
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.success().also {
Timber.v("Work cancelled due to input error from parent")
Timber.e("Work cancelled due to input error from parent")
}
Timber.v("Start Encrypt work for event ${params.event.eventId}")
@ -79,7 +78,7 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters)
var result: MXEncryptEventContentResult? = null
try {
result = awaitCallback {
crypto.encryptEventContent(localMutableContent, localEvent.type, params.roomId, it)
crypto.encryptEventContent(localMutableContent, localEvent.type, localEvent.roomId!!, it)
}
} catch (throwable: Throwable) {
error = throwable
@ -97,7 +96,7 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters)
type = safeResult.eventType,
content = safeResult.eventContent
)
val nextWorkerParams = SendEventWorker.Params(params.sessionId, params.roomId, encryptedEvent)
val nextWorkerParams = SendEventWorker.Params(params.sessionId, encryptedEvent)
return Result.success(WorkerParamsFactory.toData(nextWorkerParams))
} else {
val sendState = when (error) {
@ -106,7 +105,7 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters)
}
localEchoUpdater.updateSendState(localEvent.eventId, sendState)
// always return success, or the chain will be stuck for ever!
val nextWorkerParams = SendEventWorker.Params(params.sessionId, params.roomId, localEvent, error?.localizedMessage
val nextWorkerParams = SendEventWorker.Params(params.sessionId, localEvent, error?.localizedMessage
?: "Error")
return Result.success(WorkerParamsFactory.toData(nextWorkerParams))
}

View file

@ -0,0 +1,103 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.room.send
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.CoroutineWorker
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.internal.di.WorkManagerProvider
import im.vector.matrix.android.internal.session.room.timeline.TimelineSendEventWorkCommon
import im.vector.matrix.android.internal.worker.SessionWorkerParams
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import im.vector.matrix.android.internal.worker.getSessionComponent
import im.vector.matrix.android.internal.worker.startChain
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
/**
* This worker creates a new work for each events passed in parameter
*/
internal class MultipleEventSendingDispatcherWorker(context: Context, params: WorkerParameters)
: CoroutineWorker(context, params) {
@JsonClass(generateAdapter = true)
internal data class Params(
override val sessionId: String,
val events: List<Event>,
val isEncrypted: Boolean,
override val lastFailureMessage: String? = null
) : SessionWorkerParams
@Inject lateinit var workManagerProvider: WorkManagerProvider
@Inject lateinit var timelineSendEventWorkCommon: TimelineSendEventWorkCommon
override suspend fun doWork(): Result {
Timber.v("Start dispatch sending multiple event work")
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.success().also {
Timber.e("Work cancelled due to input error from parent")
}
if (params.lastFailureMessage != null) {
// Transmit the error
return Result.success(inputData)
}
val sessionComponent = getSessionComponent(params.sessionId) ?: return Result.success()
sessionComponent.inject(this)
// Create a work for every event
params.events.forEach { event ->
if (params.isEncrypted) {
Timber.v("Send event in encrypted room")
val encryptWork = createEncryptEventWork(params.sessionId, event, true)
// Note that event will be replaced by the result of the previous work
val sendWork = createSendEventWork(params.sessionId, event, false)
timelineSendEventWorkCommon.postSequentialWorks(event.roomId!!, encryptWork, sendWork)
} else {
val sendWork = createSendEventWork(params.sessionId, event, true)
timelineSendEventWorkCommon.postWork(event.roomId!!, sendWork)
}
}
return Result.success()
}
private fun createEncryptEventWork(sessionId: String, event: Event, startChain: Boolean): OneTimeWorkRequest {
val params = EncryptEventWorker.Params(sessionId, event)
val sendWorkData = WorkerParamsFactory.toData(params)
return workManagerProvider.matrixOneTimeWorkRequestBuilder<EncryptEventWorker>()
.setConstraints(WorkManagerProvider.workConstraints)
.setInputData(sendWorkData)
.startChain(startChain)
.setBackoffCriteria(BackoffPolicy.LINEAR, WorkManagerProvider.BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}
private fun createSendEventWork(sessionId: String, event: Event, startChain: Boolean): OneTimeWorkRequest {
val sendContentWorkerParams = SendEventWorker.Params(sessionId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)
return timelineSendEventWorkCommon.createWork<SendEventWorker>(sendWorkData, startChain)
}
}

View file

@ -21,7 +21,6 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.failure.shouldBeRetried
import im.vector.matrix.android.api.session.events.model.Content
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.internal.network.executeRequest
@ -30,6 +29,7 @@ import im.vector.matrix.android.internal.worker.SessionWorkerParams
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import im.vector.matrix.android.internal.worker.getSessionComponent
import org.greenrobot.eventbus.EventBus
import timber.log.Timber
import javax.inject.Inject
internal class SendEventWorker(context: Context,
@ -39,7 +39,6 @@ internal class SendEventWorker(context: Context,
@JsonClass(generateAdapter = true)
internal data class Params(
override val sessionId: String,
val roomId: String,
val event: Event,
override val lastFailureMessage: String? = null
) : SessionWorkerParams
@ -50,7 +49,9 @@ internal class SendEventWorker(context: Context,
override suspend fun doWork(): Result {
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.success()
?: return Result.success().also {
Timber.e("Work cancelled due to input error from parent")
}
val sessionComponent = getSessionComponent(params.sessionId) ?: return Result.success()
sessionComponent.inject(this)
@ -66,7 +67,7 @@ internal class SendEventWorker(context: Context,
return Result.success(inputData)
}
return try {
sendEvent(event.eventId, event.type, event.content, params.roomId)
sendEvent(event)
Result.success()
} catch (exception: Throwable) {
if (exception.shouldBeRetried()) {
@ -79,16 +80,16 @@ internal class SendEventWorker(context: Context,
}
}
private suspend fun sendEvent(eventId: String, eventType: String, content: Content?, roomId: String) {
localEchoUpdater.updateSendState(eventId, SendState.SENDING)
private suspend fun sendEvent(event: Event) {
localEchoUpdater.updateSendState(event.eventId!!, SendState.SENDING)
executeRequest<SendResponse>(eventBus) {
apiCall = roomAPI.send(
eventId,
roomId,
eventType,
content
event.eventId,
event.roomId!!,
event.type,
event.content
)
}
localEchoUpdater.updateSendState(eventId, SendState.SENT)
localEchoUpdater.updateSendState(event.eventId, SendState.SENT)
}
}

View file

@ -341,6 +341,7 @@ dependencies {
implementation "com.github.bumptech.glide:glide:$glide_version"
kapt "com.github.bumptech.glide:compiler:$glide_version"
implementation 'com.danikula:videocache:2.7.1'
implementation 'com.github.yalantis:ucrop:2.2.4'
// Badge for compatibility
implementation 'me.leolin:ShortcutBadger:1.1.22@aar'

View file

@ -88,7 +88,13 @@
</intent-filter>
</activity>
<activity android:name=".features.share.IncomingShareActivity">
<activity
android:name=".features.share.IncomingShareActivity"
android:parentActivityName=".features.home.HomeActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".features.home.HomeActivity" />
<intent-filter>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
@ -134,6 +140,14 @@
<activity android:name=".features.qrcode.QrCodeScannerActivity" />
<activity
android:name="com.yalantis.ucrop.UCropActivity"
android:screenOrientation="portrait" />
<activity
android:name=".features.attachments.preview.AttachmentsPreviewActivity"
android:theme="@style/AppTheme.AttachmentsPreview" />
<!-- Services -->
<service

View file

@ -315,6 +315,11 @@ SOFTWARE.
<br/>
Copyright (c) 2012-2016 Dan Wheeler and Dropbox, Inc.
</li>
<li>
<b>Compressor</b>
<br/>
Copyright (c) 2016 Zetra.
</li>
<li>
<b>com.otaliastudios:autocomplete</b>
<br/>
@ -375,6 +380,11 @@ SOFTWARE.
<br/>
Copyright (c) 2014 Dushyanth Maguluru
</li>
<li>
<b>uCrop</b>
<br/>
Copyright 2017, Yalantis
</li>
<li>
<b>BillCarsonFr/JsonViewer</b>
</li>

View file

@ -22,6 +22,7 @@ import androidx.fragment.app.FragmentFactory
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewFragment
import im.vector.riotx.features.createdirect.CreateDirectRoomDirectoryUsersFragment
import im.vector.riotx.features.createdirect.CreateDirectRoomKnownUsersFragment
import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsFragment
@ -75,6 +76,7 @@ import im.vector.riotx.features.settings.devices.VectorSettingsDevicesFragment
import im.vector.riotx.features.settings.devtools.AccountDataFragment
import im.vector.riotx.features.settings.ignored.VectorSettingsIgnoredUsersFragment
import im.vector.riotx.features.settings.push.PushGatewaysFragment
import im.vector.riotx.features.share.IncomingShareFragment
import im.vector.riotx.features.signout.soft.SoftLogoutFragment
@Module
@ -350,6 +352,16 @@ interface FragmentModule {
@FragmentKey(CrossSigningSettingsFragment::class)
fun bindCrossSigningSettingsFragment(fragment: CrossSigningSettingsFragment): Fragment
@Binds
@IntoMap
@FragmentKey(AttachmentsPreviewFragment::class)
fun bindAttachmentsPreviewFragment(fragment: AttachmentsPreviewFragment): Fragment
@Binds
@IntoMap
@FragmentKey(IncomingShareFragment::class)
fun bindIncomingShareFragment(fragment: IncomingShareFragment): Fragment
@Binds
@IntoMap
@FragmentKey(AccountDataFragment::class)

View file

@ -47,7 +47,6 @@ import im.vector.riotx.features.rageshake.VectorUncaughtExceptionHandler
import im.vector.riotx.features.reactions.data.EmojiDataSource
import im.vector.riotx.features.session.SessionListener
import im.vector.riotx.features.settings.VectorPreferences
import im.vector.riotx.features.share.ShareRoomListDataSource
import im.vector.riotx.features.ui.UiStateRepository
import javax.inject.Singleton
@ -97,8 +96,6 @@ interface VectorComponent {
fun homeRoomListObservableStore(): HomeRoomListDataSource
fun shareRoomListObservableStore(): ShareRoomListDataSource
fun selectedGroupStore(): SelectedGroupDataSource
fun activeSessionObservableStore(): ActiveSessionDataSource

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.core.platform
import android.content.Context
import android.util.AttributeSet
import android.widget.Checkable
import androidx.appcompat.widget.AppCompatImageView
class CheckableImageView : AppCompatImageView, Checkable {
private var mChecked = false
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun isChecked(): Boolean {
return mChecked
}
override fun setChecked(b: Boolean) {
if (b != mChecked) {
mChecked = b
refreshDrawableState()
}
}
override fun toggle() {
isChecked = !mChecked
}
override fun onCreateDrawableState(extraSpace: Int): IntArray {
val drawableState = super.onCreateDrawableState(extraSpace + 1)
if (isChecked) {
mergeDrawableStates(drawableState, CHECKED_STATE_SET)
}
return drawableState
}
companion object {
private val CHECKED_STATE_SET = intArrayOf(android.R.attr.state_checked)
}
}

View file

@ -263,6 +263,9 @@ abstract class VectorBaseFragment : BaseMvRxFragment(), HasScreenInjector {
}
}
// This should be provided by the framework
protected fun invalidateOptionsMenu() = requireActivity().invalidateOptionsMenu()
/* ==========================================================================================
* Common Dialogs
* ========================================================================================== */

View file

@ -26,6 +26,7 @@ import javax.inject.Inject
class ColorProvider @Inject constructor(private val context: Context) {
@ColorInt
fun getColor(@ColorRes colorRes: Int): Int {
return ContextCompat.getColor(context, colorRes)
}

View file

@ -68,6 +68,7 @@ const val PERMISSION_REQUEST_CODE_CHANGE_AVATAR = 574
const val PERMISSION_REQUEST_CODE_DOWNLOAD_FILE = 575
const val PERMISSION_REQUEST_CODE_PICK_ATTACHMENT = 576
const val PERMISSION_REQUEST_CODE_INCOMING_URI = 577
const val PERMISSION_REQUEST_CODE_PREVIEW_FRAGMENT = 578
/**
* Log the used permissions statuses.

View file

@ -0,0 +1,76 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.core.utils
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SnapHelper
interface OnSnapPositionChangeListener {
fun onSnapPositionChange(position: Int)
}
fun RecyclerView.attachSnapHelperWithListener(
snapHelper: SnapHelper,
behavior: SnapOnScrollListener.Behavior = SnapOnScrollListener.Behavior.NOTIFY_ON_SCROLL_STATE_IDLE,
onSnapPositionChangeListener: OnSnapPositionChangeListener) {
snapHelper.attachToRecyclerView(this)
val snapOnScrollListener = SnapOnScrollListener(snapHelper, behavior, onSnapPositionChangeListener)
addOnScrollListener(snapOnScrollListener)
}
fun SnapHelper.getSnapPosition(recyclerView: RecyclerView): Int {
val layoutManager = recyclerView.layoutManager ?: return RecyclerView.NO_POSITION
val snapView = findSnapView(layoutManager) ?: return RecyclerView.NO_POSITION
return layoutManager.getPosition(snapView)
}
class SnapOnScrollListener(
private val snapHelper: SnapHelper,
var behavior: Behavior = Behavior.NOTIFY_ON_SCROLL,
var onSnapPositionChangeListener: OnSnapPositionChangeListener? = null
) : RecyclerView.OnScrollListener() {
enum class Behavior {
NOTIFY_ON_SCROLL,
NOTIFY_ON_SCROLL_STATE_IDLE
}
private var snapPosition = RecyclerView.NO_POSITION
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (behavior == Behavior.NOTIFY_ON_SCROLL) {
maybeNotifySnapPositionChange(recyclerView)
}
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (behavior == Behavior.NOTIFY_ON_SCROLL_STATE_IDLE
&& newState == RecyclerView.SCROLL_STATE_IDLE) {
maybeNotifySnapPositionChange(recyclerView)
}
}
private fun maybeNotifySnapPositionChange(recyclerView: RecyclerView) {
val snapPosition = snapHelper.getSnapPosition(recyclerView)
val snapPositionChanged = this.snapPosition != snapPosition
if (snapPositionChanged) {
onSnapPositionChangeListener?.onSnapPositionChange(snapPosition)
this.snapPosition = snapPosition
}
}
}

View file

@ -20,11 +20,16 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
import com.kbeanie.multipicker.api.Picker.*
import com.kbeanie.multipicker.api.Picker.PICK_AUDIO
import com.kbeanie.multipicker.api.Picker.PICK_CONTACT
import com.kbeanie.multipicker.api.Picker.PICK_FILE
import com.kbeanie.multipicker.api.Picker.PICK_IMAGE_CAMERA
import com.kbeanie.multipicker.api.Picker.PICK_IMAGE_DEVICE
import com.kbeanie.multipicker.core.PickerManager
import im.vector.matrix.android.BuildConfig
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.riotx.core.platform.Restorable
import im.vector.riotx.features.attachments.AttachmentsHelper.Callback
import timber.log.Timber
private const val CAPTURE_PATH_KEY = "CAPTURE_PATH_KEY"

View file

@ -16,7 +16,11 @@
package im.vector.riotx.features.attachments
import com.kbeanie.multipicker.api.entity.*
import com.kbeanie.multipicker.api.entity.ChosenAudio
import com.kbeanie.multipicker.api.entity.ChosenContact
import com.kbeanie.multipicker.api.entity.ChosenFile
import com.kbeanie.multipicker.api.entity.ChosenImage
import com.kbeanie.multipicker.api.entity.ChosenVideo
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import timber.log.Timber
@ -37,7 +41,8 @@ fun ChosenFile.toContentAttachmentData(): ContentAttachmentData {
type = mapType(),
size = size,
date = createdAt?.time ?: System.currentTimeMillis(),
name = displayName
name = displayName,
queryUri = queryUri
)
}
@ -50,7 +55,8 @@ fun ChosenAudio.toContentAttachmentData(): ContentAttachmentData {
size = size,
date = createdAt?.time ?: System.currentTimeMillis(),
name = displayName,
duration = duration
duration = duration,
queryUri = queryUri
)
}
@ -74,7 +80,8 @@ fun ChosenImage.toContentAttachmentData(): ContentAttachmentData {
height = height.toLong(),
width = width.toLong(),
exifOrientation = orientation,
date = createdAt?.time ?: System.currentTimeMillis()
date = createdAt?.time ?: System.currentTimeMillis(),
queryUri = queryUri
)
}
@ -89,6 +96,7 @@ fun ChosenVideo.toContentAttachmentData(): ContentAttachmentData {
height = height.toLong(),
width = width.toLong(),
duration = duration,
name = displayName
name = displayName,
queryUri = queryUri
)
}

View file

@ -21,7 +21,11 @@ import com.kbeanie.multipicker.api.callbacks.ContactPickerCallback
import com.kbeanie.multipicker.api.callbacks.FilePickerCallback
import com.kbeanie.multipicker.api.callbacks.ImagePickerCallback
import com.kbeanie.multipicker.api.callbacks.VideoPickerCallback
import com.kbeanie.multipicker.api.entity.*
import com.kbeanie.multipicker.api.entity.ChosenAudio
import com.kbeanie.multipicker.api.entity.ChosenContact
import com.kbeanie.multipicker.api.entity.ChosenFile
import com.kbeanie.multipicker.api.entity.ChosenImage
import com.kbeanie.multipicker.api.entity.ChosenVideo
/**
* This class delegates the PickerManager callbacks to an [AttachmentsHelper.Callback]

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.attachments
import im.vector.matrix.android.api.session.content.ContentAttachmentData
fun ContentAttachmentData.isPreviewable(): Boolean {
return type == ContentAttachmentData.Type.IMAGE || type == ContentAttachmentData.Type.VIDEO
}
data class GroupedContentAttachmentData(
val previewables: List<ContentAttachmentData>,
val notPreviewables: List<ContentAttachmentData>
)
fun List<ContentAttachmentData>.toGroupedContentAttachmentData(): GroupedContentAttachmentData {
return groupBy { it.isPreviewable() }
.let {
GroupedContentAttachmentData(
it[true].orEmpty(),
it[false].orEmpty()
)
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.attachments.preview
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import javax.inject.Inject
class AttachmentBigPreviewController @Inject constructor() : TypedEpoxyController<AttachmentsPreviewViewState>() {
override fun buildModels(data: AttachmentsPreviewViewState) {
data.attachments.forEach {
attachmentBigPreviewItem {
id(it.path)
attachment(it)
}
}
}
}
class AttachmentMiniaturePreviewController @Inject constructor() : TypedEpoxyController<AttachmentsPreviewViewState>() {
interface Callback {
fun onAttachmentClicked(position: Int, contentAttachmentData: ContentAttachmentData)
}
var callback: Callback? = null
override fun buildModels(data: AttachmentsPreviewViewState) {
data.attachments.forEachIndexed { index, contentAttachmentData ->
attachmentMiniaturePreviewItem {
id(contentAttachmentData.path)
attachment(contentAttachmentData)
checked(data.currentAttachmentIndex == index)
clickListener { _ ->
callback?.onAttachmentClicked(index, contentAttachmentData)
}
}
}
}
}

View file

@ -0,0 +1,86 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.attachments.preview
import android.view.View
import android.widget.ImageView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.platform.CheckableImageView
abstract class AttachmentPreviewItem<H : AttachmentPreviewItem.Holder> : VectorEpoxyModel<H>() {
abstract val attachment: ContentAttachmentData
override fun bind(holder: H) {
val path = attachment.path
if (attachment.type == ContentAttachmentData.Type.VIDEO || attachment.type == ContentAttachmentData.Type.IMAGE) {
Glide.with(holder.view.context)
.asBitmap()
.load(path)
.apply(RequestOptions().frame(0))
.into(holder.imageView)
} else {
holder.imageView.setImageResource(R.drawable.filetype_attachment)
holder.imageView.scaleType = ImageView.ScaleType.FIT_CENTER
}
}
abstract class Holder : VectorEpoxyHolder() {
abstract val imageView: ImageView
}
}
@EpoxyModelClass(layout = R.layout.item_attachment_miniature_preview)
abstract class AttachmentMiniaturePreviewItem : AttachmentPreviewItem<AttachmentMiniaturePreviewItem.Holder>() {
@EpoxyAttribute override lateinit var attachment: ContentAttachmentData
@EpoxyAttribute
var clickListener: View.OnClickListener? = null
@EpoxyAttribute
var checked: Boolean = false
override fun bind(holder: Holder) {
super.bind(holder)
holder.imageView.isChecked = checked
holder.view.setOnClickListener(clickListener)
}
class Holder : AttachmentPreviewItem.Holder() {
override val imageView: CheckableImageView
get() = miniatureImageView
private val miniatureImageView by bind<CheckableImageView>(R.id.attachmentMiniatureImageView)
}
}
@EpoxyModelClass(layout = R.layout.item_attachment_big_preview)
abstract class AttachmentBigPreviewItem : AttachmentPreviewItem<AttachmentBigPreviewItem.Holder>() {
@EpoxyAttribute override lateinit var attachment: ContentAttachmentData
class Holder : AttachmentPreviewItem.Holder() {
override val imageView: ImageView
get() = bigImageView
private val bigImageView by bind<ImageView>(R.id.attachmentBigImageView)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package im.vector.riotx.features.attachments.preview
import im.vector.riotx.core.platform.VectorViewModelAction
sealed class AttachmentsPreviewAction : VectorViewModelAction {
object RemoveCurrentAttachment : AttachmentsPreviewAction()
data class SetCurrentAttachment(val index: Int): AttachmentsPreviewAction()
data class UpdatePathOfCurrentAttachment(val newPath: String): AttachmentsPreviewAction()
}

View file

@ -0,0 +1,77 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package im.vector.riotx.features.attachments.preview
import android.content.Context
import android.content.Intent
import androidx.appcompat.widget.Toolbar
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.riotx.R
import im.vector.riotx.core.extensions.addFragment
import im.vector.riotx.core.platform.ToolbarConfigurable
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.features.themes.ActivityOtherThemes
class AttachmentsPreviewActivity : VectorBaseActivity(), ToolbarConfigurable {
companion object {
const val REQUEST_CODE = 55
private const val EXTRA_FRAGMENT_ARGS = "EXTRA_FRAGMENT_ARGS"
private const val ATTACHMENTS_PREVIEW_RESULT = "ATTACHMENTS_PREVIEW_RESULT"
private const val KEEP_ORIGINAL_IMAGES_SIZE = "KEEP_ORIGINAL_IMAGES_SIZE"
fun newIntent(context: Context, args: AttachmentsPreviewArgs): Intent {
return Intent(context, AttachmentsPreviewActivity::class.java).apply {
putExtra(EXTRA_FRAGMENT_ARGS, args)
}
}
fun getOutput(intent: Intent): List<ContentAttachmentData> {
return intent.getParcelableArrayListExtra(ATTACHMENTS_PREVIEW_RESULT)
}
fun getKeepOriginalSize(intent: Intent): Boolean {
return intent.getBooleanExtra(KEEP_ORIGINAL_IMAGES_SIZE, false)
}
}
override fun getOtherThemes() = ActivityOtherThemes.AttachmentsPreview
override fun getLayoutRes() = R.layout.activity_simple
override fun initUiAndData() {
if (isFirstCreation()) {
val fragmentArgs: AttachmentsPreviewArgs = intent?.extras?.getParcelable(EXTRA_FRAGMENT_ARGS) ?: return
addFragment(R.id.simpleFragmentContainer, AttachmentsPreviewFragment::class.java, fragmentArgs)
}
}
fun setResultAndFinish(data: List<ContentAttachmentData>, keepOriginalImageSize: Boolean) {
val resultIntent = Intent().apply {
putParcelableArrayListExtra(ATTACHMENTS_PREVIEW_RESULT, ArrayList(data))
putExtra(KEEP_ORIGINAL_IMAGES_SIZE, keepOriginalImageSize)
}
setResult(RESULT_OK, resultIntent)
finish()
}
override fun configure(toolbar: Toolbar) {
configureToolbar(toolbar)
}
}

View file

@ -0,0 +1,255 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package im.vector.riotx.features.attachments.preview
import android.app.Activity.RESULT_CANCELED
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.os.Parcelable
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.yalantis.ucrop.UCrop
import com.yalantis.ucrop.UCropActivity
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.riotx.R
import im.vector.riotx.core.extensions.cleanup
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.utils.OnSnapPositionChangeListener
import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_PREVIEW_FRAGMENT
import im.vector.riotx.core.utils.SnapOnScrollListener
import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.attachSnapHelperWithListener
import im.vector.riotx.core.utils.checkPermissions
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_attachments_preview.*
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@Parcelize
data class AttachmentsPreviewArgs(
val attachments: List<ContentAttachmentData>
) : Parcelable
class AttachmentsPreviewFragment @Inject constructor(
val viewModelFactory: AttachmentsPreviewViewModel.Factory,
private val attachmentMiniaturePreviewController: AttachmentMiniaturePreviewController,
private val attachmentBigPreviewController: AttachmentBigPreviewController,
private val colorProvider: ColorProvider
) : VectorBaseFragment(), AttachmentMiniaturePreviewController.Callback {
private val fragmentArgs: AttachmentsPreviewArgs by args()
private val viewModel: AttachmentsPreviewViewModel by fragmentViewModel()
override fun getLayoutResId() = R.layout.fragment_attachments_preview
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
applyInsets()
setupRecyclerViews()
setupToolbar(attachmentPreviewerToolbar)
attachmentPreviewerSendButton.setOnClickListener {
setResultAndFinish()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (resultCode == RESULT_OK) {
if (requestCode == UCrop.REQUEST_CROP && data != null) {
Timber.v("Crop success")
handleCropResult(data)
}
}
if (resultCode == UCrop.RESULT_ERROR) {
Timber.v("Crop error")
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.attachmentsPreviewRemoveAction -> {
handleRemoveAction()
true
}
R.id.attachmentsPreviewEditAction -> {
handleEditAction()
true
}
else -> {
super.onOptionsItemSelected(item)
}
}
}
override fun onPrepareOptionsMenu(menu: Menu) {
withState(viewModel) { state ->
val editMenuItem = menu.findItem(R.id.attachmentsPreviewEditAction)
val showEditMenuItem = state.attachments[state.currentAttachmentIndex].isEditable()
editMenuItem.setVisible(showEditMenuItem)
}
super.onPrepareOptionsMenu(menu)
}
override fun getMenuRes() = R.menu.vector_attachments_preview
override fun onDestroyView() {
super.onDestroyView()
attachmentPreviewerMiniatureList.cleanup()
attachmentPreviewerBigList.cleanup()
attachmentMiniaturePreviewController.callback = null
}
override fun invalidate() = withState(viewModel) { state ->
invalidateOptionsMenu()
if (state.attachments.isEmpty()) {
requireActivity().setResult(RESULT_CANCELED)
requireActivity().finish()
} else {
attachmentMiniaturePreviewController.setData(state)
attachmentBigPreviewController.setData(state)
attachmentPreviewerBigList.scrollToPosition(state.currentAttachmentIndex)
attachmentPreviewerMiniatureList.scrollToPosition(state.currentAttachmentIndex)
attachmentPreviewerSendImageOriginalSize.text = resources.getQuantityString(R.plurals.send_images_with_original_size, state.attachments.size)
}
}
override fun onAttachmentClicked(position: Int, contentAttachmentData: ContentAttachmentData) {
viewModel.handle(AttachmentsPreviewAction.SetCurrentAttachment(position))
}
private fun setResultAndFinish() = withState(viewModel) {
(requireActivity() as? AttachmentsPreviewActivity)?.setResultAndFinish(
it.attachments,
attachmentPreviewerSendImageOriginalSize.isChecked
)
}
private fun applyInsets() {
view?.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
ViewCompat.setOnApplyWindowInsetsListener(attachmentPreviewerBottomContainer) { v, insets ->
v.updatePadding(bottom = insets.systemWindowInsetBottom)
insets
}
ViewCompat.setOnApplyWindowInsetsListener(attachmentPreviewerToolbar) { v, insets ->
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.systemWindowInsetTop
}
insets
}
}
private fun handleCropResult(result: Intent) {
val resultPath = UCrop.getOutput(result)?.path
if (resultPath != null) {
viewModel.handle(AttachmentsPreviewAction.UpdatePathOfCurrentAttachment(resultPath))
} else {
Toast.makeText(requireContext(), "Cannot retrieve cropped value", Toast.LENGTH_SHORT).show()
}
}
private fun handleRemoveAction() {
viewModel.handle(AttachmentsPreviewAction.RemoveCurrentAttachment)
}
private fun handleEditAction() {
// check permissions
if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_PREVIEW_FRAGMENT)) {
doHandleEditAction()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_CODE_PREVIEW_FRAGMENT && allGranted(grantResults)) {
doHandleEditAction()
}
}
private fun doHandleEditAction() = withState(viewModel) {
val currentAttachment = it.attachments.getOrNull(it.currentAttachmentIndex) ?: return@withState
val destinationFile = File(requireContext().cacheDir, "${currentAttachment.name}_edited_image_${System.currentTimeMillis()}")
UCrop.of(currentAttachment.queryUri.toUri(), destinationFile.toUri())
.withOptions(
UCrop.Options()
.apply {
setAllowedGestures(
/* tabScale = */ UCropActivity.SCALE,
/* tabRotate = */ UCropActivity.ALL,
/* tabAspectRatio = */ UCropActivity.SCALE
)
setToolbarTitle(currentAttachment.name)
setFreeStyleCropEnabled(true)
// Color used for toolbar icon and text
setToolbarColor(colorProvider.getColorFromAttribute(R.attr.riotx_background))
setToolbarWidgetColor(colorProvider.getColorFromAttribute(R.attr.vctr_toolbar_primary_text_color))
// Background
setRootViewBackgroundColor(colorProvider.getColorFromAttribute(R.attr.riotx_background))
// Status bar color (pb in dark mode, icon of the status bar are dark)
setStatusBarColor(colorProvider.getColorFromAttribute(R.attr.riotx_header_panel_background))
// Known issue: there is still orange color used by the lib
// https://github.com/Yalantis/uCrop/issues/602
setActiveControlsWidgetColor(colorProvider.getColor(R.color.riotx_accent))
// Hide the logo (does not work)
setLogoColor(Color.TRANSPARENT)
}
)
.start(requireContext(), this)
}
private fun setupRecyclerViews() {
attachmentMiniaturePreviewController.callback = this
attachmentPreviewerMiniatureList.let {
it.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
it.setHasFixedSize(true)
it.adapter = attachmentMiniaturePreviewController.adapter
}
attachmentPreviewerBigList.let {
it.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
it.attachSnapHelperWithListener(
PagerSnapHelper(),
SnapOnScrollListener.Behavior.NOTIFY_ON_SCROLL_STATE_IDLE,
object : OnSnapPositionChangeListener {
override fun onSnapPositionChange(position: Int) {
viewModel.handle(AttachmentsPreviewAction.SetCurrentAttachment(position))
}
})
it.setHasFixedSize(true)
it.adapter = attachmentBigPreviewController.adapter
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2019 New Vector Ltd
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -12,14 +12,11 @@
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package im.vector.riotx.features.share
package im.vector.riotx.features.attachments.preview
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotx.core.utils.BehaviorDataSource
import javax.inject.Inject
import javax.inject.Singleton
import im.vector.riotx.core.platform.VectorViewEvents
@Singleton
class ShareRoomListDataSource @Inject constructor() : BehaviorDataSource<List<RoomSummary>>()
sealed class AttachmentsPreviewViewEvents : VectorViewEvents

View file

@ -0,0 +1,78 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package im.vector.riotx.features.attachments.preview
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.VectorViewModel
class AttachmentsPreviewViewModel @AssistedInject constructor(@Assisted initialState: AttachmentsPreviewViewState)
: VectorViewModel<AttachmentsPreviewViewState, AttachmentsPreviewAction, AttachmentsPreviewViewEvents>(initialState) {
@AssistedInject.Factory
interface Factory {
fun create(initialState: AttachmentsPreviewViewState): AttachmentsPreviewViewModel
}
companion object : MvRxViewModelFactory<AttachmentsPreviewViewModel, AttachmentsPreviewViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: AttachmentsPreviewViewState): AttachmentsPreviewViewModel? {
val fragment: AttachmentsPreviewFragment = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.viewModelFactory.create(state)
}
}
override fun handle(action: AttachmentsPreviewAction) {
when (action) {
is AttachmentsPreviewAction.SetCurrentAttachment -> handleSetCurrentAttachment(action)
is AttachmentsPreviewAction.UpdatePathOfCurrentAttachment -> handleUpdatePathOfCurrentAttachment(action)
AttachmentsPreviewAction.RemoveCurrentAttachment -> handleRemoveCurrentAttachment()
}.exhaustive
}
private fun handleRemoveCurrentAttachment() = withState {
val currentAttachment = it.attachments.getOrNull(it.currentAttachmentIndex) ?: return@withState
val attachments = it.attachments.minusElement(currentAttachment)
val newAttachmentIndex = it.currentAttachmentIndex.coerceAtMost(attachments.size - 1)
setState {
copy(attachments = attachments, currentAttachmentIndex = newAttachmentIndex)
}
}
private fun handleUpdatePathOfCurrentAttachment(action: AttachmentsPreviewAction.UpdatePathOfCurrentAttachment) = withState {
val attachments = it.attachments.mapIndexed { index, contentAttachmentData ->
if (index == it.currentAttachmentIndex) {
contentAttachmentData.copy(path = action.newPath)
} else {
contentAttachmentData
}
}
setState {
copy(attachments = attachments)
}
}
private fun handleSetCurrentAttachment(action: AttachmentsPreviewAction.SetCurrentAttachment) = setState {
copy(currentAttachmentIndex = action.index)
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package im.vector.riotx.features.attachments.preview
import com.airbnb.mvrx.MvRxState
import im.vector.matrix.android.api.session.content.ContentAttachmentData
data class AttachmentsPreviewViewState(
val attachments: List<ContentAttachmentData>,
val currentAttachmentIndex: Int = 0,
val sendImagesWithOriginalSize: Boolean = false
) : MvRxState {
constructor(args: AttachmentsPreviewArgs) : this(attachments = args.attachments)
}

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.attachments.preview
import im.vector.matrix.android.api.session.content.ContentAttachmentData
/**
* All images are editable, expect Gif
*/
fun ContentAttachmentData.isEditable(): Boolean {
return type == ContentAttachmentData.Type.IMAGE
&& mimeType?.startsWith("image/") == true
&& mimeType != "image/gif"
}

View file

@ -149,7 +149,7 @@ class CreateDirectRoomKnownUsersFragment @Inject constructor(
}
private fun renderSelectedUsers(selectedUsers: Set<User>) {
vectorBaseActivity.invalidateOptionsMenu()
invalidateOptionsMenu()
if (selectedUsers.isNotEmpty() && chipGroup.size == 0) {
selectedUsers.forEach { addChipToGroup(it, chipGroup) }
}

View file

@ -23,6 +23,5 @@ enum class RoomListDisplayMode(@StringRes val titleRes: Int) {
HOME(R.string.bottom_action_home),
PEOPLE(R.string.bottom_action_people_x),
ROOMS(R.string.bottom_action_rooms),
FILTERED(/* Not used */ 0),
SHARE(/* Not used */ 0)
FILTERED(/* Not used */ 0)
}

View file

@ -27,7 +27,7 @@ sealed class RoomDetailAction : VectorViewModelAction {
data class UserIsTyping(val isTyping: Boolean) : RoomDetailAction()
data class SaveDraft(val draft: String) : RoomDetailAction()
data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : RoomDetailAction()
data class SendMedia(val attachments: List<ContentAttachmentData>) : RoomDetailAction()
data class SendMedia(val attachments: List<ContentAttachmentData>, val compressBeforeSending: Boolean) : RoomDetailAction()
data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailAction()
data class TimelineEventTurnsInvisible(val event: TimelineEvent) : RoomDetailAction()
data class LoadMoreTimelineEvents(val direction: Timeline.Direction) : RoomDetailAction()

View file

@ -116,6 +116,9 @@ import im.vector.riotx.core.utils.toast
import im.vector.riotx.features.attachments.AttachmentTypeSelectorView
import im.vector.riotx.features.attachments.AttachmentsHelper
import im.vector.riotx.features.attachments.ContactAttachment
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewActivity
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewArgs
import im.vector.riotx.features.attachments.toGroupedContentAttachmentData
import im.vector.riotx.features.command.Command
import im.vector.riotx.features.crypto.util.toImageRes
import im.vector.riotx.features.crypto.verification.VerificationBottomSheet
@ -299,10 +302,15 @@ class RoomDetailFragment @Inject constructor(
super.onActivityCreated(savedInstanceState)
if (savedInstanceState == null) {
when (val sharedData = roomDetailArgs.sharedData) {
is SharedData.Text -> roomDetailViewModel.handle(RoomDetailAction.SendMessage(sharedData.text, false))
is SharedData.Attachments -> roomDetailViewModel.handle(RoomDetailAction.SendMedia(sharedData.attachmentData))
null -> Timber.v("No share data to process")
}
is SharedData.Text -> {
roomDetailViewModel.handle(RoomDetailAction.ExitSpecialMode(composerLayout.text.toString()))
}
is SharedData.Attachments -> {
// open share edition
onContentAttachmentsReady(sharedData.attachmentData)
}
null -> Timber.v("No share data to process")
}.exhaustive
}
}
@ -498,7 +506,12 @@ class RoomDetailFragment @Inject constructor(
val hasBeenHandled = attachmentsHelper.onActivityResult(requestCode, resultCode, data)
if (!hasBeenHandled && resultCode == RESULT_OK && data != null) {
when (requestCode) {
REACTION_SELECT_REQUEST_CODE -> {
AttachmentsPreviewActivity.REQUEST_CODE -> {
val sendData = AttachmentsPreviewActivity.getOutput(data)
val keepOriginalSize = AttachmentsPreviewActivity.getKeepOriginalSize(data)
roomDetailViewModel.handle(RoomDetailAction.SendMedia(sendData, !keepOriginalSize))
}
REACTION_SELECT_REQUEST_CODE -> {
val (eventId, reaction) = EmojiReactionPickerActivity.getOutput(data) ?: return
roomDetailViewModel.handle(RoomDetailAction.SendReaction(eventId, reaction))
}
@ -637,9 +650,11 @@ class RoomDetailFragment @Inject constructor(
}
private fun sendUri(uri: Uri): Boolean {
roomDetailViewModel.preventAttachmentPreview = true
val shareIntent = Intent(Intent.ACTION_SEND, uri)
val isHandled = attachmentsHelper.handleShareIntent(shareIntent)
if (!isHandled) {
roomDetailViewModel.preventAttachmentPreview = false
Toast.makeText(requireContext(), R.string.error_handling_incoming_share, Toast.LENGTH_SHORT).show()
}
return isHandled
@ -1334,10 +1349,24 @@ class RoomDetailFragment @Inject constructor(
// AttachmentsHelper.Callback
override fun onContentAttachmentsReady(attachments: List<ContentAttachmentData>) {
roomDetailViewModel.handle(RoomDetailAction.SendMedia(attachments))
if (roomDetailViewModel.preventAttachmentPreview) {
roomDetailViewModel.preventAttachmentPreview = false
roomDetailViewModel.handle(RoomDetailAction.SendMedia(attachments, false))
} else {
val grouped = attachments.toGroupedContentAttachmentData()
if (grouped.notPreviewables.isNotEmpty()) {
// Send the not previewable attachments right now (?)
roomDetailViewModel.handle(RoomDetailAction.SendMedia(grouped.notPreviewables, false))
}
if (grouped.previewables.isNotEmpty()) {
val intent = AttachmentsPreviewActivity.newIntent(requireContext(), AttachmentsPreviewArgs(grouped.previewables))
startActivityForResult(intent, AttachmentsPreviewActivity.REQUEST_CODE)
}
}
}
override fun onAttachmentsProcessFailed() {
roomDetailViewModel.preventAttachmentPreview = false
Toast.makeText(requireContext(), R.string.error_attachment, Toast.LENGTH_SHORT).show()
}

View file

@ -115,6 +115,8 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
var pendingAction: RoomDetailAction? = null
// Slot to keep a pending uri during permission request
var pendingUri: Uri? = null
// Slot to store if we want to prevent preview of attachment
var preventAttachmentPreview = false
private var trackUnreadMessages = AtomicBoolean(false)
private var mostRecentDisplayedEvent: TimelineEvent? = null
@ -582,10 +584,10 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
if (maxUploadFileSize == HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN) {
// Unknown limitation
room.sendMedias(attachments)
room.sendMedias(attachments, action.compressBeforeSending, emptySet())
} else {
when (val tooBigFile = attachments.find { it.size > maxUploadFileSize }) {
null -> room.sendMedias(attachments)
null -> room.sendMedias(attachments, action.compressBeforeSending, emptySet())
else -> _viewEvents.post(RoomDetailViewEvents.FileTooBigError(
tooBigFile.name ?: tooBigFile.path,
tooBigFile.size,

View file

@ -33,7 +33,6 @@ class RoomListDisplayModeFilter(private val displayMode: RoomListDisplayMode) :
RoomListDisplayMode.PEOPLE -> roomSummary.isDirect && roomSummary.membership == Membership.JOIN
RoomListDisplayMode.ROOMS -> !roomSummary.isDirect && roomSummary.membership == Membership.JOIN
RoomListDisplayMode.FILTERED -> roomSummary.membership == Membership.JOIN
RoomListDisplayMode.SHARE -> roomSummary.membership == Membership.JOIN
}
}
}

View file

@ -50,15 +50,13 @@ import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsShare
import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsSharedActionViewModel
import im.vector.riotx.features.home.room.list.widget.FabMenuView
import im.vector.riotx.features.notifications.NotificationDrawerManager
import im.vector.riotx.features.share.SharedData
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_list.*
import javax.inject.Inject
@Parcelize
data class RoomListParams(
val displayMode: RoomListDisplayMode,
val sharedData: SharedData? = null
val displayMode: RoomListDisplayMode
) : Parcelable
class RoomListFragment @Inject constructor(
@ -106,7 +104,7 @@ class RoomListFragment @Inject constructor(
when (it) {
is RoomListViewEvents.Loading -> showLoading(it.message)
is RoomListViewEvents.Failure -> showFailure(it.throwable)
is RoomListViewEvents.SelectRoom -> openSelectedRoom(it)
is RoomListViewEvents.SelectRoom -> handleSelectRoom(it)
}.exhaustive
}
@ -131,13 +129,8 @@ class RoomListFragment @Inject constructor(
super.onDestroyView()
}
private fun openSelectedRoom(event: RoomListViewEvents.SelectRoom) {
if (roomListParams.displayMode == RoomListDisplayMode.SHARE) {
val sharedData = roomListParams.sharedData ?: return
navigator.openRoomForSharing(requireActivity(), event.roomId, sharedData)
} else {
navigator.openRoom(requireActivity(), event.roomId)
}
private fun handleSelectRoom(event: RoomListViewEvents.SelectRoom) {
navigator.openRoom(requireActivity(), event.roomSummary.roomId)
}
private fun setupCreateRoomButton() {
@ -256,7 +249,6 @@ class RoomListFragment @Inject constructor(
is Fail -> renderFailure(state.asyncFilteredRooms.error)
}
roomController.update(state)
// Mark all as read menu
when (roomListParams.displayMode) {
RoomListDisplayMode.HOME,
@ -265,7 +257,7 @@ class RoomListFragment @Inject constructor(
val newValue = state.hasUnread
if (hasUnreadRooms != newValue) {
hasUnreadRooms = newValue
requireActivity().invalidateOptionsMenu()
invalidateOptionsMenu()
}
}
else -> Unit
@ -338,7 +330,6 @@ class RoomListFragment @Inject constructor(
if (createChatFabMenu.onBackPressed()) {
return true
}
return false
}
@ -350,7 +341,6 @@ class RoomListFragment @Inject constructor(
override fun onRoomLongClicked(room: RoomSummary): Boolean {
roomController.onRoomLongClicked()
RoomListQuickActionsBottomSheet
.newInstance(room.roomId, RoomListActionsArgs.Mode.FULL)
.show(childFragmentManager, "ROOM_LIST_QUICK_ACTIONS")

View file

@ -17,6 +17,7 @@
package im.vector.riotx.features.home.room.list
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotx.core.platform.VectorViewEvents
/**
@ -26,5 +27,5 @@ sealed class RoomListViewEvents : VectorViewEvents {
data class Loading(val message: CharSequence? = null) : RoomListViewEvents()
data class Failure(val throwable: Throwable) : RoomListViewEvents()
data class SelectRoom(val roomId: String) : RoomListViewEvents()
data class SelectRoom(val roomSummary: RoomSummary) : RoomListViewEvents()
}

View file

@ -25,9 +25,9 @@ import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.tag.RoomTag
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.utils.DataSource
import im.vector.riotx.features.home.RoomListDisplayMode
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import javax.inject.Inject
@ -67,13 +67,13 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
is RoomListAction.MarkAllRoomsRead -> handleMarkAllRoomsRead()
is RoomListAction.LeaveRoom -> handleLeaveRoom(action)
is RoomListAction.ChangeRoomNotificationState -> handleChangeNotificationMode(action)
}
}.exhaustive
}
// PRIVATE METHODS *****************************************************************************
private fun handleSelectRoom(action: RoomListAction.SelectRoom) {
_viewEvents.post(RoomListViewEvents.SelectRoom(action.roomSummary.roomId))
private fun handleSelectRoom(action: RoomListAction.SelectRoom) = withState {
_viewEvents.post(RoomListViewEvents.SelectRoom(action.roomSummary))
}
private fun handleToggleCategory(action: RoomListAction.ToggleCategory) = setState {
@ -204,54 +204,35 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
}
private fun buildRoomSummaries(rooms: List<RoomSummary>): RoomSummaries {
if (displayMode == RoomListDisplayMode.SHARE) {
val recentRooms = ArrayList<RoomSummary>(20)
val otherRooms = ArrayList<RoomSummary>(rooms.size)
// Set up init size on directChats and groupRooms as they are the biggest ones
val invites = ArrayList<RoomSummary>()
val favourites = ArrayList<RoomSummary>()
val directChats = ArrayList<RoomSummary>(rooms.size)
val groupRooms = ArrayList<RoomSummary>(rooms.size)
val lowPriorities = ArrayList<RoomSummary>()
val serverNotices = ArrayList<RoomSummary>()
rooms
.filter { roomListDisplayModeFilter.test(it) }
.forEach { room ->
when (room.breadcrumbsIndex) {
RoomSummary.NOT_IN_BREADCRUMBS -> otherRooms.add(room)
else -> recentRooms.add(room)
}
rooms
.filter { roomListDisplayModeFilter.test(it) }
.forEach { room ->
val tags = room.tags.map { it.name }
when {
room.membership == Membership.INVITE -> invites.add(room)
tags.contains(RoomTag.ROOM_TAG_SERVER_NOTICE) -> serverNotices.add(room)
tags.contains(RoomTag.ROOM_TAG_FAVOURITE) -> favourites.add(room)
tags.contains(RoomTag.ROOM_TAG_LOW_PRIORITY) -> lowPriorities.add(room)
room.isDirect -> directChats.add(room)
else -> groupRooms.add(room)
}
}
return RoomSummaries().apply {
put(RoomCategory.RECENT_ROOMS, recentRooms)
put(RoomCategory.OTHER_ROOMS, otherRooms)
}
} else {
// Set up init size on directChats and groupRooms as they are the biggest ones
val invites = ArrayList<RoomSummary>()
val favourites = ArrayList<RoomSummary>()
val directChats = ArrayList<RoomSummary>(rooms.size)
val groupRooms = ArrayList<RoomSummary>(rooms.size)
val lowPriorities = ArrayList<RoomSummary>()
val serverNotices = ArrayList<RoomSummary>()
rooms
.filter { roomListDisplayModeFilter.test(it) }
.forEach { room ->
val tags = room.tags.map { it.name }
when {
room.membership == Membership.INVITE -> invites.add(room)
tags.contains(RoomTag.ROOM_TAG_SERVER_NOTICE) -> serverNotices.add(room)
tags.contains(RoomTag.ROOM_TAG_FAVOURITE) -> favourites.add(room)
tags.contains(RoomTag.ROOM_TAG_LOW_PRIORITY) -> lowPriorities.add(room)
room.isDirect -> directChats.add(room)
else -> groupRooms.add(room)
}
}
return RoomSummaries().apply {
put(RoomCategory.INVITE, invites)
put(RoomCategory.FAVOURITE, favourites)
put(RoomCategory.DIRECT, directChats)
put(RoomCategory.GROUP, groupRooms)
put(RoomCategory.LOW_PRIORITY, lowPriorities)
put(RoomCategory.SERVER_NOTICE, serverNotices)
}
return RoomSummaries().apply {
put(RoomCategory.INVITE, invites)
put(RoomCategory.FAVOURITE, favourites)
put(RoomCategory.DIRECT, directChats)
put(RoomCategory.GROUP, groupRooms)
put(RoomCategory.LOW_PRIORITY, lowPriorities)
put(RoomCategory.SERVER_NOTICE, serverNotices)
}
}
}

View file

@ -18,21 +18,18 @@ package im.vector.riotx.features.home.room.list
import im.vector.matrix.android.api.session.Session
import im.vector.riotx.features.home.HomeRoomListDataSource
import im.vector.riotx.features.home.RoomListDisplayMode
import im.vector.riotx.features.share.ShareRoomListDataSource
import javax.inject.Inject
import javax.inject.Provider
class RoomListViewModelFactory @Inject constructor(private val session: Provider<Session>,
private val homeRoomListDataSource: Provider<HomeRoomListDataSource>,
private val shareRoomListDataSource: Provider<ShareRoomListDataSource>)
private val homeRoomListDataSource: Provider<HomeRoomListDataSource>)
: RoomListViewModel.Factory {
override fun create(initialState: RoomListViewState): RoomListViewModel {
return RoomListViewModel(
initialState,
session.get(),
if (initialState.displayMode == RoomListDisplayMode.SHARE) shareRoomListDataSource.get() else homeRoomListDataSource.get()
homeRoomListDataSource.get()
)
}
}

View file

@ -43,10 +43,7 @@ data class RoomListViewState(
val isDirectRoomsExpanded: Boolean = true,
val isGroupRoomsExpanded: Boolean = true,
val isLowPriorityRoomsExpanded: Boolean = true,
val isServerNoticeRoomsExpanded: Boolean = true,
// For sharing
val isRecentExpanded: Boolean = true,
val isOtherExpanded: Boolean = true
val isServerNoticeRoomsExpanded: Boolean = true
) : MvRxState {
constructor(args: RoomListParams) : this(displayMode = args.displayMode)
@ -59,8 +56,6 @@ data class RoomListViewState(
RoomCategory.GROUP -> isGroupRoomsExpanded
RoomCategory.LOW_PRIORITY -> isLowPriorityRoomsExpanded
RoomCategory.SERVER_NOTICE -> isServerNoticeRoomsExpanded
RoomCategory.RECENT_ROOMS -> isRecentExpanded
RoomCategory.OTHER_ROOMS -> isOtherExpanded
}
}
@ -72,8 +67,6 @@ data class RoomListViewState(
RoomCategory.GROUP -> copy(isGroupRoomsExpanded = !isGroupRoomsExpanded)
RoomCategory.LOW_PRIORITY -> copy(isLowPriorityRoomsExpanded = !isLowPriorityRoomsExpanded)
RoomCategory.SERVER_NOTICE -> copy(isServerNoticeRoomsExpanded = !isServerNoticeRoomsExpanded)
RoomCategory.RECENT_ROOMS -> copy(isRecentExpanded = !isRecentExpanded)
RoomCategory.OTHER_ROOMS -> copy(isOtherExpanded = !isOtherExpanded)
}
}
@ -93,11 +86,7 @@ enum class RoomCategory(@StringRes val titleRes: Int) {
DIRECT(R.string.bottom_action_people_x),
GROUP(R.string.bottom_action_rooms),
LOW_PRIORITY(R.string.low_priority_header),
SERVER_NOTICE(R.string.system_alerts_header),
// For Sharing
RECENT_ROOMS(R.string.room_list_sharing_header_recent_rooms),
OTHER_ROOMS(R.string.room_list_sharing_header_other_rooms)
SERVER_NOTICE(R.string.system_alerts_header)
}
fun RoomSummaries?.isNullOrEmpty(): Boolean {

View file

@ -22,7 +22,6 @@ import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.helpFooterItem
import im.vector.riotx.core.epoxy.noResultItem
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.resources.UserPreferencesProvider
import im.vector.riotx.features.home.RoomListDisplayMode
@ -60,7 +59,6 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri
val nonNullViewState = viewState ?: return
when (nonNullViewState.displayMode) {
RoomListDisplayMode.FILTERED -> buildFilteredRooms(nonNullViewState)
RoomListDisplayMode.SHARE -> buildShareRooms(nonNullViewState)
else -> buildRooms(nonNullViewState)
}
}
@ -77,44 +75,12 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri
viewState.joiningRoomsIds,
viewState.joiningErrorRoomsIds,
viewState.rejectingRoomsIds,
viewState.rejectingErrorRoomsIds)
viewState.rejectingErrorRoomsIds,
emptySet())
addFilterFooter(viewState)
}
private fun buildShareRooms(viewState: RoomListViewState) {
var hasResult = false
val roomSummaries = viewState.asyncFilteredRooms()
roomListNameFilter.filter = viewState.roomFilter
roomSummaries?.forEach { (category, summaries) ->
val filteredSummaries = summaries
.filter { it.membership == Membership.JOIN && roomListNameFilter.test(it) }
if (filteredSummaries.isEmpty()) {
return@forEach
} else {
hasResult = true
val isExpanded = viewState.isCategoryExpanded(category)
buildRoomCategory(viewState, emptyList(), category.titleRes, viewState.isCategoryExpanded(category)) {
listener?.onToggleRoomCategory(category)
}
if (isExpanded) {
buildRoomModels(filteredSummaries,
emptySet(),
emptySet(),
emptySet(),
emptySet()
)
}
}
}
if (!hasResult) {
addNoResultItem()
}
}
private fun buildRooms(viewState: RoomListViewState) {
var showHelp = false
val roomSummaries = viewState.asyncFilteredRooms()
@ -131,7 +97,8 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri
viewState.joiningRoomsIds,
viewState.joiningErrorRoomsIds,
viewState.rejectingRoomsIds,
viewState.rejectingErrorRoomsIds)
viewState.rejectingErrorRoomsIds,
emptySet())
// Never set showHelp to true for invitation
if (category != RoomCategory.INVITE) {
showHelp = userPreferencesProvider.shouldShowLongClickOnRoomHelp()
@ -160,13 +127,6 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri
}
}
private fun addNoResultItem() {
noResultItem {
id("no_result")
text(stringProvider.getString(R.string.no_result_placeholder))
}
}
private fun buildRoomCategory(viewState: RoomListViewState,
summaries: List<RoomSummary>,
@StringRes titleRes: Int,
@ -196,10 +156,17 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri
joiningRoomsIds: Set<String>,
joiningErrorRoomsIds: Set<String>,
rejectingRoomsIds: Set<String>,
rejectingErrorRoomsIds: Set<String>) {
rejectingErrorRoomsIds: Set<String>,
selectedRoomIds: Set<String>) {
summaries.forEach { roomSummary ->
roomSummaryItemFactory
.create(roomSummary, joiningRoomsIds, joiningErrorRoomsIds, rejectingRoomsIds, rejectingErrorRoomsIds, listener)
.create(roomSummary,
joiningRoomsIds,
joiningErrorRoomsIds,
rejectingRoomsIds,
rejectingErrorRoomsIds,
selectedRoomIds,
listener)
.addTo(this)
}
}

View file

@ -16,14 +16,17 @@
package im.vector.riotx.features.home.room.list
import android.view.HapticFeedbackConstants
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.amulyakhare.textdrawable.TextDrawable
import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel
import im.vector.matrix.android.api.util.MatrixItem
import im.vector.riotx.R
@ -48,11 +51,15 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
@EpoxyAttribute var showHighlighted: Boolean = false
@EpoxyAttribute var itemLongClickListener: View.OnLongClickListener? = null
@EpoxyAttribute var itemClickListener: View.OnClickListener? = null
@EpoxyAttribute var showSelected: Boolean = false
override fun bind(holder: Holder) {
super.bind(holder)
holder.rootView.setOnClickListener(itemClickListener)
holder.rootView.setOnLongClickListener(itemLongClickListener)
holder.rootView.setOnLongClickListener {
it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
itemLongClickListener?.onLongClick(it) ?: false
}
holder.titleView.text = matrixItem.getBestName()
holder.lastEventTimeView.text = lastEventTime
holder.lastEventView.text = lastFormattedEvent
@ -64,6 +71,19 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
avatarRenderer.render(matrixItem, holder.avatarImageView)
holder.roomAvatarDecorationImageView.isVisible = encryptionTrustLevel != null
holder.roomAvatarDecorationImageView.setImageResource(encryptionTrustLevel.toImageRes())
renderSelection(holder, showSelected)
}
private fun renderSelection(holder: Holder, isSelected: Boolean) {
if (isSelected) {
holder.avatarCheckedImageView.visibility = View.VISIBLE
val backgroundColor = ContextCompat.getColor(holder.view.context, R.color.riotx_accent)
val backgroundDrawable = TextDrawable.builder().buildRound("", backgroundColor)
holder.avatarImageView.setImageDrawable(backgroundDrawable)
} else {
holder.avatarCheckedImageView.visibility = View.GONE
avatarRenderer.render(matrixItem, holder.avatarImageView)
}
}
class Holder : VectorEpoxyHolder() {
@ -74,6 +94,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
val typingView by bind<TextView>(R.id.roomTypingView)
val draftView by bind<ImageView>(R.id.roomDraftBadge)
val lastEventTimeView by bind<TextView>(R.id.roomLastEventTimeView)
val avatarCheckedImageView by bind<ImageView>(R.id.roomAvatarCheckedImageView)
val avatarImageView by bind<ImageView>(R.id.roomAvatarImageView)
val roomAvatarDecorationImageView by bind<ImageView>(R.id.roomAvatarDecorationImageView)
val rootView by bind<ViewGroup>(R.id.itemRoomLayout)

View file

@ -25,7 +25,6 @@ import im.vector.riotx.R
import im.vector.riotx.core.date.VectorDateFormatter
import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.extensions.localDateTime
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.resources.DateProvider
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.utils.DebouncedClickListener
@ -36,7 +35,6 @@ import javax.inject.Inject
class RoomSummaryItemFactory @Inject constructor(private val displayableEventFormatter: DisplayableEventFormatter,
private val dateFormatter: VectorDateFormatter,
private val colorProvider: ColorProvider,
private val stringProvider: StringProvider,
private val typingHelper: TypingHelper,
private val session: Session,
@ -47,19 +45,20 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor
joiningErrorRoomsIds: Set<String>,
rejectingRoomsIds: Set<String>,
rejectingErrorRoomsIds: Set<String>,
selectedRoomIds: Set<String>,
listener: RoomSummaryController.Listener?): VectorEpoxyModel<*> {
return when (roomSummary.membership) {
Membership.INVITE -> createInvitationItem(roomSummary, joiningRoomsIds, joiningErrorRoomsIds, rejectingRoomsIds, rejectingErrorRoomsIds, listener)
else -> createRoomItem(roomSummary, listener)
else -> createRoomItem(roomSummary, selectedRoomIds, listener?.let { it::onRoomClicked }, listener?.let { it::onRoomLongClicked })
}
}
private fun createInvitationItem(roomSummary: RoomSummary,
joiningRoomsIds: Set<String>,
joiningErrorRoomsIds: Set<String>,
rejectingRoomsIds: Set<String>,
rejectingErrorRoomsIds: Set<String>,
listener: RoomSummaryController.Listener?): VectorEpoxyModel<*> {
fun createInvitationItem(roomSummary: RoomSummary,
joiningRoomsIds: Set<String>,
joiningErrorRoomsIds: Set<String>,
rejectingRoomsIds: Set<String>,
rejectingErrorRoomsIds: Set<String>,
listener: RoomSummaryController.Listener?): VectorEpoxyModel<*> {
val secondLine = if (roomSummary.isDirect) {
roomSummary.latestPreviewableEvent?.root?.senderId
} else {
@ -82,10 +81,15 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor
.listener { listener?.onRoomClicked(roomSummary) }
}
private fun createRoomItem(roomSummary: RoomSummary, listener: RoomSummaryController.Listener?): VectorEpoxyModel<*> {
fun createRoomItem(
roomSummary: RoomSummary,
selectedRoomIds: Set<String>,
onClick: ((RoomSummary) -> Unit)?,
onLongClick: ((RoomSummary) -> Boolean)?
): VectorEpoxyModel<*> {
val unreadCount = roomSummary.notificationCount
val showHighlighted = roomSummary.highlightCount > 0
val showSelected = selectedRoomIds.contains(roomSummary.roomId)
var latestFormattedEvent: CharSequence = ""
var latestEventTime: CharSequence = ""
val latestEvent = roomSummary.latestPreviewableEvent
@ -119,15 +123,16 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor
.typingString(typingString)
.lastFormattedEvent(latestFormattedEvent)
.showHighlighted(showHighlighted)
.showSelected(showSelected)
.unreadNotificationCount(unreadCount)
.hasUnreadMessage(roomSummary.hasUnreadMessages)
.hasDraft(roomSummary.userDrafts.isNotEmpty())
.itemLongClickListener { _ ->
listener?.onRoomLongClicked(roomSummary) ?: false
onLongClick?.invoke(roomSummary) ?: false
}
.itemClickListener(
DebouncedClickListener(View.OnClickListener { _ ->
listener?.onRoomClicked(roomSummary)
onClick?.invoke(roomSummary)
})
)
}

View file

@ -64,15 +64,15 @@ class DefaultNavigator @Inject constructor(
startActivity(context, intent, buildTask)
}
override fun performDeviceVerification(context: Context, otherUserId: String, sasTransationId: String) {
override fun performDeviceVerification(context: Context, otherUserId: String, sasTransactionId: String) {
val session = sessionHolder.getSafeActiveSession() ?: return
val tx = session.cryptoService().verificationService().getExistingTransaction(otherUserId, sasTransationId) ?: return
val tx = session.cryptoService().verificationService().getExistingTransaction(otherUserId, sasTransactionId) ?: return
(tx as? IncomingSasVerificationTransaction)?.performAccept()
if (context is VectorBaseActivity) {
VerificationBottomSheet.withArgs(
roomId = null,
otherUserId = otherUserId,
transactionId = sasTransationId
transactionId = sasTransactionId
).show(context.supportFragmentManager, "REQPOP")
}
}
@ -126,7 +126,7 @@ class DefaultNavigator @Inject constructor(
startActivity(context, intent, buildTask)
}
override fun openRoomForSharing(activity: Activity, roomId: String, sharedData: SharedData) {
override fun openRoomForSharingAndFinish(activity: Activity, roomId: String, sharedData: SharedData) {
val args = RoomDetailArgs(roomId, null, sharedData)
val intent = RoomDetailActivity.newIntent(activity, args)
activity.startActivity(intent)

View file

@ -26,11 +26,13 @@ interface Navigator {
fun openRoom(context: Context, roomId: String, eventId: String? = null, buildTask: Boolean = false)
fun performDeviceVerification(context: Context, otherUserId: String, sasTransationId: String)
fun performDeviceVerification(context: Context, otherUserId: String, sasTransactionId: String)
fun requestSessionVerification(context: Context)
fun waitSessionVerification(context: Context)
fun openRoomForSharing(activity: Activity, roomId: String, sharedData: SharedData)
fun openRoomForSharingAndFinish(activity: Activity, roomId: String, sharedData: SharedData)
fun openNotJoinedRoom(context: Context, roomIdOrAlias: String?, eventId: String? = null, buildTask: Boolean = false)

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.share
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotx.core.platform.VectorViewModelAction
sealed class IncomingShareAction : VectorViewModelAction {
data class SelectRoom(val roomSummary: RoomSummary, val enableMultiSelect: Boolean) : IncomingShareAction()
object ShareToSelectedRooms : IncomingShareAction()
data class ShareMedia(val keepOriginalSize: Boolean) : IncomingShareAction()
data class FilterWith(val filter: String) : IncomingShareAction()
data class UpdateSharedData(val sharedData: SharedData) : IncomingShareAction()
}

View file

@ -9,126 +9,30 @@
*
* 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.
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.V
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.share
import android.content.ClipDescription
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.widget.SearchView
import com.airbnb.mvrx.viewModel
import com.kbeanie.multipicker.utils.IntentUtils
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import androidx.appcompat.widget.Toolbar
import im.vector.riotx.R
import im.vector.riotx.core.di.ActiveSessionHolder
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.replaceFragment
import im.vector.riotx.core.extensions.addFragment
import im.vector.riotx.core.platform.ToolbarConfigurable
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.features.attachments.AttachmentsHelper
import im.vector.riotx.features.home.LoadingFragment
import im.vector.riotx.features.home.RoomListDisplayMode
import im.vector.riotx.features.home.room.list.RoomListFragment
import im.vector.riotx.features.home.room.list.RoomListParams
import im.vector.riotx.features.login.LoginActivity
import kotlinx.android.synthetic.main.activity_incoming_share.*
import javax.inject.Inject
class IncomingShareActivity :
VectorBaseActivity(), AttachmentsHelper.Callback {
class IncomingShareActivity : VectorBaseActivity(), ToolbarConfigurable {
@Inject lateinit var sessionHolder: ActiveSessionHolder
@Inject lateinit var incomingShareViewModelFactory: IncomingShareViewModel.Factory
private lateinit var attachmentsHelper: AttachmentsHelper
// Do not remove, even if not used, it instantiates the view model
@Suppress("unused")
private val viewModel: IncomingShareViewModel by viewModel()
private val roomListFragment: RoomListFragment?
get() {
return supportFragmentManager.findFragmentById(R.id.shareRoomListFragmentContainer) as? RoomListFragment
}
override fun getLayoutRes() = R.layout.activity_simple
override fun getLayoutRes() = R.layout.activity_incoming_share
override fun injectWith(injector: ScreenComponent) {
injector.inject(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// If we are not logged in, stop the sharing process and open login screen.
// In the future, we might want to relaunch the sharing process after login.
if (!sessionHolder.hasActiveSession()) {
startLoginActivity()
return
}
configureToolbar(incomingShareToolbar)
override fun initUiAndData() {
if (isFirstCreation()) {
replaceFragment(R.id.shareRoomListFragmentContainer, LoadingFragment::class.java)
addFragment(R.id.simpleFragmentContainer, IncomingShareFragment::class.java)
}
attachmentsHelper = AttachmentsHelper.create(this, this).register()
if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_SEND_MULTIPLE) {
var isShareManaged = attachmentsHelper.handleShareIntent(
IntentUtils.getPickerIntentForSharing(intent)
)
if (!isShareManaged) {
isShareManaged = handleTextShare(intent)
}
if (!isShareManaged) {
cannotManageShare()
}
} else {
cannotManageShare()
}
incomingShareSearchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
return true
}
override fun onQueryTextChange(newText: String): Boolean {
roomListFragment?.filterRoomsWith(newText)
return true
}
})
}
override fun onContentAttachmentsReady(attachments: List<ContentAttachmentData>) {
val roomListParams = RoomListParams(RoomListDisplayMode.SHARE, sharedData = SharedData.Attachments(attachments))
replaceFragment(R.id.shareRoomListFragmentContainer, RoomListFragment::class.java, roomListParams)
}
override fun onAttachmentsProcessFailed() {
cannotManageShare()
}
private fun cannotManageShare() {
Toast.makeText(this, R.string.error_handling_incoming_share, Toast.LENGTH_LONG).show()
finish()
}
private fun handleTextShare(intent: Intent): Boolean {
if (intent.type == ClipDescription.MIMETYPE_TEXT_PLAIN) {
val sharedText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
return if (sharedText.isNullOrEmpty()) {
false
} else {
val roomListParams = RoomListParams(RoomListDisplayMode.SHARE, sharedData = SharedData.Text(sharedText))
replaceFragment(R.id.shareRoomListFragmentContainer, RoomListFragment::class.java, roomListParams)
true
}
}
return false
}
private fun startLoginActivity() {
val intent = LoginActivity.newIntent(this, null)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
override fun configure(toolbar: Toolbar) {
configureToolbar(toolbar, displayBack = false)
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.share
import com.airbnb.epoxy.TypedEpoxyController
import com.airbnb.mvrx.Incomplete
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.loadingItem
import im.vector.riotx.core.epoxy.noResultItem
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.home.room.list.RoomSummaryItemFactory
import javax.inject.Inject
class IncomingShareController @Inject constructor(private val roomSummaryItemFactory: RoomSummaryItemFactory,
private val stringProvider: StringProvider) : TypedEpoxyController<IncomingShareViewState>() {
interface Callback {
fun onRoomClicked(roomSummary: RoomSummary)
fun onRoomLongClicked(roomSummary: RoomSummary): Boolean
}
var callback: Callback? = null
override fun buildModels(data: IncomingShareViewState) {
if (data.sharedData == null || data.filteredRoomSummaries is Incomplete) {
loadingItem {
id("loading")
}
return
}
val roomSummaries = data.filteredRoomSummaries()
if (roomSummaries.isNullOrEmpty()) {
noResultItem {
id("no_result")
text(stringProvider.getString(R.string.no_result_placeholder))
}
} else {
roomSummaries.forEach { roomSummary ->
roomSummaryItemFactory
.createRoomItem(roomSummary, data.selectedRoomIds, callback?.let { it::onRoomClicked }, callback?.let { it::onRoomLongClicked })
.addTo(this)
}
}
}
}

View file

@ -0,0 +1,241 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.share
import android.app.Activity
import android.content.ClipDescription
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.kbeanie.multipicker.utils.IntentUtils
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotx.R
import im.vector.riotx.core.di.ActiveSessionHolder
import im.vector.riotx.core.extensions.cleanup
import im.vector.riotx.core.extensions.configureWith
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_PICK_ATTACHMENT
import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.checkPermissions
import im.vector.riotx.features.attachments.AttachmentsHelper
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewActivity
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewArgs
import im.vector.riotx.features.login.LoginActivity
import kotlinx.android.synthetic.main.fragment_incoming_share.*
import javax.inject.Inject
/**
* Display the list of rooms
* The user can select multiple rooms to send the data to
*/
class IncomingShareFragment @Inject constructor(
val incomingShareViewModelFactory: IncomingShareViewModel.Factory,
private val incomingShareController: IncomingShareController,
private val sessionHolder: ActiveSessionHolder
) : VectorBaseFragment(), AttachmentsHelper.Callback, IncomingShareController.Callback {
private lateinit var attachmentsHelper: AttachmentsHelper
private val viewModel: IncomingShareViewModel by fragmentViewModel()
override fun getLayoutResId() = R.layout.fragment_incoming_share
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// If we are not logged in, stop the sharing process and open login screen.
// In the future, we might want to relaunch the sharing process after login.
if (!sessionHolder.hasActiveSession()) {
startLoginActivity()
return
}
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
setupToolbar(incomingShareToolbar)
attachmentsHelper = AttachmentsHelper.create(this, this).register()
val intent = vectorBaseActivity.intent
val isShareManaged = when (intent?.action) {
Intent.ACTION_SEND -> {
var isShareManaged = attachmentsHelper.handleShareIntent(IntentUtils.getPickerIntentForSharing(intent))
if (!isShareManaged) {
isShareManaged = handleTextShare(intent)
}
isShareManaged
}
Intent.ACTION_SEND_MULTIPLE -> attachmentsHelper.handleShareIntent(intent)
else -> false
}
if (!isShareManaged) {
cannotManageShare(R.string.error_handling_incoming_share)
}
incomingShareSearchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
return true
}
override fun onQueryTextChange(newText: String): Boolean {
viewModel.handle(IncomingShareAction.FilterWith(newText))
return true
}
})
sendShareButton.setOnClickListener { _ ->
handleSendShare()
}
viewModel.observeViewEvents {
when (it) {
is IncomingShareViewEvents.ShareToRoom -> handleShareToRoom(it)
is IncomingShareViewEvents.EditMediaBeforeSending -> handleEditMediaBeforeSending(it)
is IncomingShareViewEvents.MultipleRoomsShareDone -> handleMultipleRoomsShareDone(it)
}.exhaustive
}
}
private fun handleMultipleRoomsShareDone(viewEvent: IncomingShareViewEvents.MultipleRoomsShareDone) {
requireActivity().let {
navigator.openRoom(it, viewEvent.roomId)
it.finish()
}
}
private fun handleEditMediaBeforeSending(event: IncomingShareViewEvents.EditMediaBeforeSending) {
val intent = AttachmentsPreviewActivity.newIntent(requireContext(), AttachmentsPreviewArgs(event.contentAttachmentData))
startActivityForResult(intent, AttachmentsPreviewActivity.REQUEST_CODE)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val hasBeenHandled = attachmentsHelper.onActivityResult(requestCode, resultCode, data)
if (!hasBeenHandled && resultCode == Activity.RESULT_OK && data != null) {
when (requestCode) {
AttachmentsPreviewActivity.REQUEST_CODE -> {
val sendData = AttachmentsPreviewActivity.getOutput(data)
val keepOriginalSize = AttachmentsPreviewActivity.getKeepOriginalSize(data)
viewModel.handle(IncomingShareAction.UpdateSharedData(SharedData.Attachments(sendData)))
viewModel.handle(IncomingShareAction.ShareMedia(keepOriginalSize))
}
}
}
}
override fun onResume() {
super.onResume()
// We need the read file permission
checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_PICK_ATTACHMENT)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_CODE_PICK_ATTACHMENT && !allGranted(grantResults)) {
// Permission is mandatory
cannotManageShare(R.string.missing_permissions_error)
}
}
private fun handleShareToRoom(event: IncomingShareViewEvents.ShareToRoom) {
if (event.showAlert) {
showConfirmationDialog(event.roomSummary, event.sharedData)
} else {
navigator.openRoomForSharingAndFinish(requireActivity(), event.roomSummary.roomId, event.sharedData)
}
}
private fun handleSendShare() {
viewModel.handle(IncomingShareAction.ShareToSelectedRooms)
}
override fun onDestroyView() {
incomingShareController.callback = null
incomingShareRoomList.cleanup()
super.onDestroyView()
}
private fun setupRecyclerView() {
incomingShareRoomList.configureWith(incomingShareController, hasFixedSize = true)
incomingShareController.callback = this
}
override fun onContentAttachmentsReady(attachments: List<ContentAttachmentData>) {
val sharedData = SharedData.Attachments(attachments)
viewModel.handle(IncomingShareAction.UpdateSharedData(sharedData))
}
override fun onAttachmentsProcessFailed() {
cannotManageShare(R.string.error_handling_incoming_share)
}
private fun cannotManageShare(@StringRes messageResId: Int) {
Toast.makeText(requireContext(), messageResId, Toast.LENGTH_LONG).show()
requireActivity().finish()
}
private fun handleTextShare(intent: Intent): Boolean {
if (intent.type == ClipDescription.MIMETYPE_TEXT_PLAIN) {
val sharedText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
return if (sharedText.isNullOrEmpty()) {
false
} else {
val sharedData = SharedData.Text(sharedText)
viewModel.handle(IncomingShareAction.UpdateSharedData(sharedData))
true
}
}
return false
}
private fun showConfirmationDialog(roomSummary: RoomSummary, sharedData: SharedData) {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.send_attachment)
.setMessage(getString(R.string.share_confirm_room, roomSummary.displayName))
.setPositiveButton(R.string.send) { _, _ ->
navigator.openRoomForSharingAndFinish(requireActivity(), roomSummary.roomId, sharedData)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun startLoginActivity() {
val intent = LoginActivity.newIntent(requireActivity(), null)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
requireActivity().finish()
}
override fun invalidate() = withState(viewModel) {
sendShareButton.isVisible = it.isInMultiSelectionMode
incomingShareController.setData(it)
}
override fun onRoomClicked(roomSummary: RoomSummary) {
viewModel.handle(IncomingShareAction.SelectRoom(roomSummary, false))
}
override fun onRoomLongClicked(roomSummary: RoomSummary): Boolean {
viewModel.handle(IncomingShareAction.SelectRoom(roomSummary, true))
return true
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.share
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotx.core.platform.VectorViewEvents
sealed class IncomingShareViewEvents : VectorViewEvents {
data class ShareToRoom(val roomSummary: RoomSummary,
val sharedData: SharedData,
val showAlert: Boolean) : IncomingShareViewEvents()
data class EditMediaBeforeSending(val contentAttachmentData: List<ContentAttachmentData>) : IncomingShareViewEvents()
data class MultipleRoomsShareDone(val roomId: String) : IncomingShareViewEvents()
}

View file

@ -16,71 +16,183 @@
package im.vector.riotx.features.share
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.extensions.orFalse
import im.vector.matrix.android.api.query.QueryStringValue
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.roomSummaryQueryParams
import im.vector.matrix.rx.rx
import im.vector.riotx.ActiveSessionDataSource
import im.vector.riotx.core.platform.EmptyAction
import im.vector.riotx.core.platform.EmptyViewEvents
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.attachments.isPreviewable
import im.vector.riotx.features.attachments.toGroupedContentAttachmentData
import im.vector.riotx.features.home.room.list.BreadcrumbsRoomComparator
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import java.util.concurrent.TimeUnit
data class IncomingShareState(private val dummy: Boolean = false) : MvRxState
/**
* View model used to observe the room list and post update to the ShareRoomListObservableStore
*/
class IncomingShareViewModel @AssistedInject constructor(@Assisted initialState: IncomingShareState,
private val sessionObservableStore: ActiveSessionDataSource,
private val shareRoomListObservableStore: ShareRoomListDataSource,
private val breadcrumbsRoomComparator: BreadcrumbsRoomComparator)
: VectorViewModel<IncomingShareState, EmptyAction, EmptyViewEvents>(initialState) {
class IncomingShareViewModel @AssistedInject constructor(
@Assisted initialState: IncomingShareViewState,
private val session: Session,
private val breadcrumbsRoomComparator: BreadcrumbsRoomComparator)
: VectorViewModel<IncomingShareViewState, IncomingShareAction, IncomingShareViewEvents>(initialState) {
@AssistedInject.Factory
interface Factory {
fun create(initialState: IncomingShareState): IncomingShareViewModel
fun create(initialState: IncomingShareViewState): IncomingShareViewModel
}
companion object : MvRxViewModelFactory<IncomingShareViewModel, IncomingShareState> {
companion object : MvRxViewModelFactory<IncomingShareViewModel, IncomingShareViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: IncomingShareState): IncomingShareViewModel? {
val activity: IncomingShareActivity = (viewModelContext as ActivityViewModelContext).activity()
return activity.incomingShareViewModelFactory.create(state)
override fun create(viewModelContext: ViewModelContext, state: IncomingShareViewState): IncomingShareViewModel? {
val fragment: IncomingShareFragment = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.incomingShareViewModelFactory.create(state)
}
}
private val filterStream: BehaviorRelay<String> = BehaviorRelay.createDefault("")
init {
observeRoomSummaries()
}
private fun observeRoomSummaries() {
val queryParams = roomSummaryQueryParams()
sessionObservableStore.observe()
.observeOn(AndroidSchedulers.mainThread())
.switchMap {
it.orNull()?.rx()?.liveRoomSummaries(queryParams)
?: Observable.just(emptyList())
val queryParams = roomSummaryQueryParams {
memberships = listOf(Membership.JOIN)
}
session
.rx().liveRoomSummaries(queryParams)
.execute {
copy(roomSummaries = it)
}
filterStream
.switchMap { filter ->
val displayNameQuery = if (filter.isEmpty()) {
QueryStringValue.NoCondition
} else {
QueryStringValue.Contains(filter, QueryStringValue.Case.INSENSITIVE)
}
val filterQueryParams = roomSummaryQueryParams {
displayName = displayNameQuery
memberships = listOf(Membership.JOIN)
}
session.rx().liveRoomSummaries(filterQueryParams)
}
.throttleLast(300, TimeUnit.MILLISECONDS)
.map {
it.sortedWith(breadcrumbsRoomComparator)
.map { it.sortedWith(breadcrumbsRoomComparator) }
.execute {
copy(filteredRoomSummaries = it)
}
.subscribe {
shareRoomListObservableStore.post(it)
}
.disposeOnClear()
}
override fun handle(action: EmptyAction) {
// No op
override fun handle(action: IncomingShareAction) {
when (action) {
is IncomingShareAction.SelectRoom -> handleSelectRoom(action)
is IncomingShareAction.ShareToSelectedRooms -> handleShareToSelectedRooms()
is IncomingShareAction.ShareMedia -> handleShareMediaToSelectedRooms(action)
is IncomingShareAction.FilterWith -> handleFilter(action)
is IncomingShareAction.UpdateSharedData -> handleUpdateSharedData(action)
}.exhaustive
}
private fun handleUpdateSharedData(action: IncomingShareAction.UpdateSharedData) {
setState { copy(sharedData = action.sharedData) }
}
private fun handleFilter(action: IncomingShareAction.FilterWith) {
filterStream.accept(action.filter)
}
private fun handleShareToSelectedRooms() = withState { state ->
val sharedData = state.sharedData ?: return@withState
if (state.selectedRoomIds.size == 1) {
// In this case the edition of the media will be handled by the RoomDetailFragment
val selectedRoomId = state.selectedRoomIds.first()
val selectedRoom = state.roomSummaries()?.find { it.roomId == selectedRoomId } ?: return@withState
_viewEvents.post(IncomingShareViewEvents.ShareToRoom(selectedRoom, sharedData, showAlert = false))
} else {
when (sharedData) {
is SharedData.Text -> {
state.selectedRoomIds.forEach { roomId ->
val room = session.getRoom(roomId)
room?.sendTextMessage(sharedData.text)
}
// This is it, pass the first roomId to let the screen open it
_viewEvents.post(IncomingShareViewEvents.MultipleRoomsShareDone(state.selectedRoomIds.first()))
}
is SharedData.Attachments -> {
shareAttachments(sharedData.attachmentData, state.selectedRoomIds, proposeMediaEdition = true, compressMediaBeforeSending = false)
}
}.exhaustive
}
}
private fun handleShareMediaToSelectedRooms(action: IncomingShareAction.ShareMedia) = withState { state ->
(state.sharedData as? SharedData.Attachments)?.let {
shareAttachments(it.attachmentData, state.selectedRoomIds, proposeMediaEdition = false, compressMediaBeforeSending = !action.keepOriginalSize)
}
}
private fun shareAttachments(attachmentData: List<ContentAttachmentData>,
selectedRoomIds: Set<String>,
proposeMediaEdition: Boolean,
compressMediaBeforeSending: Boolean) {
if (proposeMediaEdition) {
val grouped = attachmentData.toGroupedContentAttachmentData()
if (grouped.notPreviewables.isNotEmpty()) {
// Send the not previewable attachments right now (?)
// Pick the first room to send the media
selectedRoomIds.firstOrNull()
?.let { roomId -> session.getRoom(roomId) }
?.sendMedias(grouped.notPreviewables, compressMediaBeforeSending, selectedRoomIds)
// Ensure they will not be sent twice
setState {
copy(
sharedData = SharedData.Attachments(grouped.previewables)
)
}
}
if (grouped.previewables.isNotEmpty()) {
// In case of multiple share of media, edit them first
_viewEvents.post(IncomingShareViewEvents.EditMediaBeforeSending(grouped.previewables))
} else {
// This is it, pass the first roomId to let the screen open it
_viewEvents.post(IncomingShareViewEvents.MultipleRoomsShareDone(selectedRoomIds.first()))
}
} else {
// Pick the first room to send the media
selectedRoomIds.firstOrNull()
?.let { roomId -> session.getRoom(roomId) }
?.sendMedias(attachmentData, compressMediaBeforeSending, selectedRoomIds)
// This is it, pass the first roomId to let the screen open it
_viewEvents.post(IncomingShareViewEvents.MultipleRoomsShareDone(selectedRoomIds.first()))
}
}
private fun handleSelectRoom(action: IncomingShareAction.SelectRoom) = withState { state ->
if (state.isInMultiSelectionMode) {
val selectedRooms = state.selectedRoomIds
val newSelectedRooms = if (selectedRooms.contains(action.roomSummary.roomId)) {
selectedRooms.minus(action.roomSummary.roomId)
} else {
selectedRooms.plus(action.roomSummary.roomId)
}
setState { copy(isInMultiSelectionMode = newSelectedRooms.isNotEmpty(), selectedRoomIds = newSelectedRooms) }
} else if (action.enableMultiSelect) {
setState { copy(isInMultiSelectionMode = true, selectedRoomIds = setOf(action.roomSummary.roomId)) }
} else {
val sharedData = state.sharedData ?: return@withState
// Do not show alert if the shared data contains only previewable attachments, because the user will get another chance to cancel the share
val doNotShowAlert = (sharedData as? SharedData.Attachments)?.attachmentData?.all { it.isPreviewable() }.orFalse()
_viewEvents.post(IncomingShareViewEvents.ShareToRoom(action.roomSummary, sharedData, !doNotShowAlert))
}
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.share
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.room.model.RoomSummary
data class IncomingShareViewState(
val sharedData: SharedData? = null,
val roomSummaries: Async<List<RoomSummary>> = Uninitialized,
val filteredRoomSummaries: Async<List<RoomSummary>> = Uninitialized,
val selectedRoomIds: Set<String> = emptySet(),
val isInMultiSelectionMode: Boolean = false
) : MvRxState

View file

@ -32,4 +32,10 @@ sealed class ActivityOtherThemes(@StyleRes val dark: Int,
R.style.AppTheme_Black,
R.style.AppTheme_Status
)
object AttachmentsPreview : ActivityOtherThemes(
R.style.AppTheme_AttachmentsPreview,
R.style.AppTheme_AttachmentsPreview,
R.style.AppTheme_AttachmentsPreview
)
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/riotx_accent" android:state_checked="true" />
<item android:color="@android:color/transparent" />
</selector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/checked_accent_color_selector"/>
</shape>

View file

@ -13,7 +13,6 @@
style="@style/VectorToolbarStyle"
android:layout_width="0dp"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:contentInsetStart="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View file

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/attachmentPreviewerBigList"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/item_attachment_big_preview" />
<androidx.appcompat.widget.Toolbar
android:id="@+id/attachmentPreviewerToolbar"
style="@style/VectorToolbarStyle"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="#40000000"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:title="Title"
tools:titleTextColor="@color/white" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/attachmentPreviewerBottomContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#40000000"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/attachmentPreviewerMiniatureList"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="8dp"
app:layout_constraintBottom_toTopOf="@+id/attachmentPreviewerSendImageOriginalSize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:itemCount="1"
tools:listitem="@layout/item_attachment_miniature_preview" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/attachmentPreviewerSendImageOriginalSize"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="8dp"
android:textColor="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/attachmentPreviewerMiniatureList"
tools:text="@plurals/send_images_with_original_size" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/attachmentPreviewerSendButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="@drawable/ic_send"
app:layout_constraintBottom_toTopOf="@id/attachmentPreviewerBottomContainer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/attachmentPreviewerBottomContainer" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/incomingShareToolbar"
style="@style/VectorToolbarStyle"
android:layout_width="0dp"
android:layout_height="?attr/actionBarSize"
app:contentInsetStart="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.SearchView
android:id="@+id/incomingShareSearchView"
style="@style/VectorSearchView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:queryHint="@string/room_filtering_filter_hint"
app:searchIcon="@drawable/ic_filter" />
</androidx.appcompat.widget.Toolbar>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/incomingShareRoomList"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/incomingShareToolbar"
tools:listitem="@layout/item_room" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/sendShareButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:accessibilityTraversalBefore="@id/incomingShareRoomList"
android:contentDescription="@string/a11y_create_room"
android:src="@drawable/ic_send"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<ImageView
android:id="@+id/attachmentBigImageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:src="@tools:sample/backgrounds/scenic" />
</FrameLayout>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:card_view="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="2dp"
card_view:cardBackgroundColor="@android:color/transparent"
card_view:cardElevation="0dp">
<im.vector.riotx.core.platform.CheckableImageView
android:id="@+id/attachmentMiniatureImageView"
android:layout_width="64dp"
android:layout_height="64dp"
android:background="@drawable/background_checked_accent_color"
android:padding="2dp"
android:scaleType="centerCrop"
tools:src="@tools:sample/backgrounds/scenic" />
</androidx.cardview.widget.CardView>

View file

@ -21,21 +21,37 @@
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<ImageView
android:id="@+id/roomAvatarImageView"
android:layout_width="56dp"
android:layout_height="56dp"
<FrameLayout
android:id="@+id/roomAvatarContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/roomAvatarImageView"
android:layout_width="56dp"
android:layout_height="56dp"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/roomAvatarCheckedImageView"
android:layout_width="56dp"
android:layout_height="56dp"
android:scaleType="centerInside"
android:src="@drawable/ic_material_done"
android:tint="@android:color/white"
android:visibility="visible" />
</FrameLayout>
<ImageView
android:id="@+id/roomAvatarDecorationImageView"
android:layout_width="24dp"
android:layout_height="24dp"
app:layout_constraintCircle="@+id/roomAvatarImageView"
app:layout_constraintCircle="@id/roomAvatarContainer"
app:layout_constraintCircleAngle="135"
app:layout_constraintCircleRadius="28dp"
tools:ignore="MissingConstraints"
@ -47,7 +63,7 @@
android:layout_width="0dp"
android:layout_height="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/roomAvatarImageView"
app:layout_constraintTop_toBottomOf="@id/roomAvatarContainer"
tools:layout_marginStart="20dp" />
<im.vector.riotx.core.platform.EllipsizingTextView
@ -69,7 +85,7 @@
app:layout_constraintEnd_toStartOf="@+id/roomDraftBadge"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/roomAvatarImageView"
app:layout_constraintStart_toEndOf="@id/roomAvatarContainer"
app:layout_constraintTop_toTopOf="parent"
tools:text="@sample/matrix.json/data/displayName" />

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<menu 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"
tools:context=".features.attachments.preview.AttachmentsPreviewActivity">
<item
android:id="@+id/attachmentsPreviewRemoveAction"
android:icon="@drawable/ic_delete"
android:title="@string/delete"
app:showAsAction="always" />
<item
android:id="@+id/attachmentsPreviewEditAction"
android:icon="@drawable/ic_edit"
android:title="@string/edit"
app:showAsAction="always" />
</menu>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme.AttachmentsPreview" parent="AppTheme.Base.Black">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View file

@ -23,7 +23,14 @@
<!-- BEGIN Strings added by Benoit -->
<string name="message_action_item_redact">Remove…</string>
<string name="share_confirm_room">Do you want to send this attachment to %1$s?</string>
<plurals name="send_images_with_original_size">
<item quantity="one">Send image with the original size</item>
<item quantity="other">Send images with the original size</item>
</plurals>
<string name="login_signup_username_hint">Username</string>
<!-- END Strings added by Benoit -->
<!-- BEGIN Strings added by Benoit -->
<!-- END Strings added by Benoit -->

View file

@ -8,4 +8,6 @@
<item name="colorPrimaryDark">@color/primary_color_dark</item>
</style>
<style name="AppTheme.AttachmentsPreview" parent="AppTheme.Base.Black"/>
</resources>