Merge pull request #589 from vector-im/feature/media_upload_failure

Fix media upload failure
This commit is contained in:
Benoit Marty 2019-09-26 11:30:54 +02:00 committed by GitHub
commit 99de40c980
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 769 additions and 237 deletions

View file

@ -6,12 +6,13 @@ Features:
Improvements:
- Persist active tab between sessions (#503)
- Do not upload file too big for the homeserver (#587)
Other changes:
-
Bugfix:
-
- Fix issue on upload error in loop (#587)
Translations:
-

View file

@ -22,6 +22,7 @@ import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import okio.Buffer
import timber.log.Timber
import java.io.IOException
import java.nio.charset.Charset
import javax.inject.Inject
@ -58,15 +59,21 @@ internal class CurlLoggingInterceptor @Inject constructor(private val logger: Ht
val requestBody = request.body()
if (requestBody != null) {
val buffer = Buffer()
requestBody.writeTo(buffer)
var charset: Charset? = UTF8
val contentType = requestBody.contentType()
if (contentType != null) {
charset = contentType.charset(UTF8)
if (requestBody.contentLength() > 100_000) {
Timber.w("Unable to log curl command data, size is too big (${requestBody.contentLength()})")
// Ensure the curl command will failed
curlCmd += "DATA IS TOO BIG"
} else {
val buffer = Buffer()
requestBody.writeTo(buffer)
var charset: Charset? = UTF8
val contentType = requestBody.contentType()
if (contentType != null) {
charset = contentType.charset(UTF8)
}
// try to keep to a single line and use a subshell to preserve any line breaks
curlCmd += " --data $'" + buffer.readString(charset!!).replace("\n", "\\n") + "'"
}
// try to keep to a single line and use a subshell to preserve any line breaks
curlCmd += " --data $'" + buffer.readString(charset!!).replace("\n", "\\n") + "'"
}
val headers = request.headers()

View file

@ -26,6 +26,7 @@ import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.api.session.group.GroupService
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilitiesService
import im.vector.matrix.android.api.session.pushers.PushersService
import im.vector.matrix.android.api.session.room.RoomDirectoryService
import im.vector.matrix.android.api.session.room.RoomService
@ -52,6 +53,7 @@ interface Session :
PushRuleService,
PushersService,
InitialSyncProgressService,
HomeServerCapabilitiesService,
SecureStorageService {
/**

View file

@ -0,0 +1,28 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.api.session.homeserver
data class HomeServerCapabilities(
/**
* Max size of file which can be uploaded to the homeserver in bytes. [MAX_UPLOAD_FILE_SIZE_UNKNOWN] if unknown or not retrieved yet
*/
val maxUploadFileSize: Long
) {
companion object {
const val MAX_UPLOAD_FILE_SIZE_UNKNOWN = -1L
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.api.session.homeserver
/**
* This interface defines a method to retrieve the homeserver capabilities.
*/
interface HomeServerCapabilitiesService {
/**
* Get the HomeServer capabilities
*/
fun getHomeServerCapabilities(): HomeServerCapabilities
}

View file

@ -17,7 +17,6 @@
package im.vector.matrix.android.internal.crypto.attachments
import android.util.Base64
import arrow.core.Try
import im.vector.matrix.android.internal.crypto.model.rest.EncryptedFileInfo
import im.vector.matrix.android.internal.crypto.model.rest.EncryptedFileKey
import timber.log.Timber
@ -50,7 +49,7 @@ object MXEncryptedAttachments {
* @param mimetype the mime type
* @return the encryption file info
*/
fun encryptAttachment(attachmentStream: InputStream, mimetype: String): Try<EncryptionResult> {
fun encryptAttachment(attachmentStream: InputStream, mimetype: String): EncryptionResult {
val t0 = System.currentTimeMillis()
val secureRandom = SecureRandom()
@ -70,7 +69,7 @@ object MXEncryptedAttachments {
val outStream = ByteArrayOutputStream()
try {
outStream.use {
val encryptCipher = Cipher.getInstance(CIPHER_ALGORITHM)
val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM)
val ivParameterSpec = IvParameterSpec(initVectorBytes)
@ -114,19 +113,7 @@ object MXEncryptedAttachments {
)
Timber.v("Encrypt in ${System.currentTimeMillis() - t0} ms")
return Try.just(result)
} catch (oom: OutOfMemoryError) {
Timber.e(oom, "## encryptAttachment failed")
return Try.Failure(oom)
} catch (e: Exception) {
Timber.e(e, "## encryptAttachment failed")
return Try.Failure(e)
} finally {
try {
outStream.close()
} catch (e: Exception) {
Timber.e(e, "## encryptAttachment() : fail to close outStream")
}
return result
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.database.mapper
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilities
import im.vector.matrix.android.internal.database.model.HomeServerCapabilitiesEntity
/**
* HomeServerCapabilitiesEntity <-> HomeSeverCapabilities
*/
internal object HomeServerCapabilitiesMapper {
fun map(entity: HomeServerCapabilitiesEntity): HomeServerCapabilities {
return HomeServerCapabilities(
entity.maxUploadFileSize
)
}
fun map(domain: HomeServerCapabilities): HomeServerCapabilitiesEntity {
return HomeServerCapabilitiesEntity(
domain.maxUploadFileSize
)
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.database.model
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilities
import io.realm.RealmObject
internal open class HomeServerCapabilitiesEntity(
var maxUploadFileSize: Long = HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN,
var lastUpdatedTimestamp: Long = 0L
) : RealmObject() {
companion object
}

View file

@ -45,6 +45,7 @@ import io.realm.annotations.RealmModule
PusherDataEntity::class,
ReadReceiptsSummaryEntity::class,
UserDraftsEntity::class,
DraftEntity::class
DraftEntity::class,
HomeServerCapabilitiesEntity::class
])
internal class SessionRealmModule

View file

@ -0,0 +1,37 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.database.query
import im.vector.matrix.android.internal.database.model.HomeServerCapabilitiesEntity
import io.realm.Realm
import io.realm.kotlin.createObject
import io.realm.kotlin.where
/**
* Get the current HomeServerCapabilitiesEntity, create one if it does not exist
*/
internal fun HomeServerCapabilitiesEntity.Companion.getOrCreate(realm: Realm): HomeServerCapabilitiesEntity {
var homeServerCapabilitiesEntity = realm.where<HomeServerCapabilitiesEntity>().findFirst()
if (homeServerCapabilitiesEntity == null) {
realm.executeTransaction {
realm.createObject<HomeServerCapabilitiesEntity>()
}
homeServerCapabilitiesEntity = realm.where<HomeServerCapabilitiesEntity>().findFirst()!!
}
return homeServerCapabilitiesEntity
}

View file

@ -22,4 +22,9 @@ internal object NetworkConstants {
const val URI_API_PREFIX_PATH_R0 = "$URI_API_PREFIX_PATH/r0/"
const val URI_API_PREFIX_PATH_UNSTABLE = "$URI_API_PREFIX_PATH/unstable/"
// Media
private const val URI_API_MEDIA_PREFIX_PATH = "_matrix/media"
const val URI_API_MEDIA_PREFIX_PATH_R0 = "$URI_API_MEDIA_PREFIX_PATH/r0/"
}

View file

@ -16,24 +16,15 @@
package im.vector.matrix.android.internal.network
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import im.vector.matrix.android.api.failure.ConsentNotGivenError
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.failure.MatrixError
import im.vector.matrix.android.internal.di.MoshiProvider
import kotlinx.coroutines.CancellationException
import okhttp3.ResponseBody
import org.greenrobot.eventbus.EventBus
import retrofit2.Call
import timber.log.Timber
import java.io.IOException
internal suspend inline fun <DATA> executeRequest(block: Request<DATA>.() -> Unit) = Request<DATA>().apply(block).execute()
internal class Request<DATA> {
private val moshi: Moshi = MoshiProvider.providesMoshi()
lateinit var apiCall: Call<DATA>
suspend fun execute(): DATA {
@ -43,7 +34,7 @@ internal class Request<DATA> {
response.body()
?: throw IllegalStateException("The request returned a null body")
} else {
throw manageFailure(response.errorBody(), response.code())
throw response.toFailure()
}
} catch (exception: Throwable) {
throw when (exception) {
@ -55,32 +46,4 @@ internal class Request<DATA> {
}
}
}
private fun manageFailure(errorBody: ResponseBody?, httpCode: Int): Throwable {
if (errorBody == null) {
return RuntimeException("Error body should not be null")
}
val errorBodyStr = errorBody.string()
val matrixErrorAdapter = moshi.adapter(MatrixError::class.java)
try {
val matrixError = matrixErrorAdapter.fromJson(errorBodyStr)
if (matrixError != null) {
if (matrixError.code == MatrixError.M_CONSENT_NOT_GIVEN && !matrixError.consentUri.isNullOrBlank()) {
// Also send this error to the bus, for a global management
EventBus.getDefault().post(ConsentNotGivenError(matrixError.consentUri))
}
return Failure.ServerError(matrixError, httpCode)
}
} catch (ex: JsonDataException) {
// This is not a MatrixError
Timber.w("The error returned by the server is not a MatrixError")
}
return Failure.OtherServerError(errorBodyStr, httpCode)
}
}

View file

@ -18,14 +18,23 @@
package im.vector.matrix.android.internal.network
import com.squareup.moshi.JsonDataException
import im.vector.matrix.android.api.failure.ConsentNotGivenError
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.failure.MatrixError
import im.vector.matrix.android.internal.di.MoshiProvider
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.ResponseBody
import org.greenrobot.eventbus.EventBus
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import timber.log.Timber
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
suspend fun <T> Call<T>.awaitResponse(): Response<T> {
internal suspend fun <T> Call<T>.awaitResponse(): Response<T> {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
cancel()
@ -40,4 +49,64 @@ suspend fun <T> Call<T>.awaitResponse(): Response<T> {
}
})
}
}
}
internal suspend fun okhttp3.Call.awaitResponse(): okhttp3.Response {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
cancel()
}
enqueue(object : okhttp3.Callback {
override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
continuation.resume(response)
}
override fun onFailure(call: okhttp3.Call, e: IOException) {
continuation.resumeWithException(e)
}
})
}
}
/**
* Convert a retrofit Response to a Failure, and eventually parse errorBody to convert it to a MatrixError
*/
internal fun <T> Response<T>.toFailure(): Failure {
return toFailure(errorBody(), code())
}
/**
* Convert a okhttp3 Response to a Failure, and eventually parse errorBody to convert it to a MatrixError
*/
internal fun okhttp3.Response.toFailure(): Failure {
return toFailure(body(), code())
}
private fun toFailure(errorBody: ResponseBody?, httpCode: Int): Failure {
if (errorBody == null) {
return Failure.Unknown(RuntimeException("errorBody should not be null"))
}
val errorBodyStr = errorBody.string()
val matrixErrorAdapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java)
try {
val matrixError = matrixErrorAdapter.fromJson(errorBodyStr)
if (matrixError != null) {
if (matrixError.code == MatrixError.M_CONSENT_NOT_GIVEN && !matrixError.consentUri.isNullOrBlank()) {
// Also send this error to the bus, for a global management
EventBus.getDefault().post(ConsentNotGivenError(matrixError.consentUri))
}
return Failure.ServerError(matrixError, httpCode)
}
} catch (ex: JsonDataException) {
// This is not a MatrixError
Timber.w("The error returned by the server is not a MatrixError")
}
return Failure.OtherServerError(errorBodyStr, httpCode)
}

View file

@ -32,6 +32,7 @@ import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.api.session.group.GroupService
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilitiesService
import im.vector.matrix.android.api.session.pushers.PushersService
import im.vector.matrix.android.api.session.room.RoomDirectoryService
import im.vector.matrix.android.api.session.room.RoomService
@ -68,7 +69,8 @@ internal class DefaultSession @Inject constructor(override val sessionParams: Se
private val syncThreadProvider: Provider<SyncThread>,
private val contentUrlResolver: ContentUrlResolver,
private val contentUploadProgressTracker: ContentUploadStateTracker,
private val initialSyncProgressService: Lazy<InitialSyncProgressService>)
private val initialSyncProgressService: Lazy<InitialSyncProgressService>,
private val homeServerCapabilitiesService: Lazy<HomeServerCapabilitiesService>)
: Session,
RoomService by roomService.get(),
RoomDirectoryService by roomDirectoryService.get(),
@ -81,7 +83,8 @@ internal class DefaultSession @Inject constructor(override val sessionParams: Se
PushersService by pushersService.get(),
FileService by fileService.get(),
InitialSyncProgressService by initialSyncProgressService.get(),
SecureStorageService by secureStorageService.get() {
SecureStorageService by secureStorageService.get(),
HomeServerCapabilitiesService by homeServerCapabilitiesService.get() {
private var isOpen = false

View file

@ -30,6 +30,7 @@ import im.vector.matrix.android.internal.session.content.UploadContentWorker
import im.vector.matrix.android.internal.session.filter.FilterModule
import im.vector.matrix.android.internal.session.group.GetGroupDataWorker
import im.vector.matrix.android.internal.session.group.GroupModule
import im.vector.matrix.android.internal.session.homeserver.HomeServerCapabilitiesModule
import im.vector.matrix.android.internal.session.pushers.AddHttpPusherWorker
import im.vector.matrix.android.internal.session.pushers.PushersModule
import im.vector.matrix.android.internal.session.room.RoomModule
@ -51,6 +52,7 @@ import im.vector.matrix.android.internal.task.TaskExecutor
SessionModule::class,
RoomModule::class,
SyncModule::class,
HomeServerCapabilitiesModule::class,
SignOutModule::class,
GroupModule::class,
UserModule::class,

View file

@ -27,6 +27,7 @@ import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig
import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.api.session.InitialSyncProgressService
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilitiesService
import im.vector.matrix.android.api.session.securestorage.SecureStorageService
import im.vector.matrix.android.internal.database.LiveEntityObserver
import im.vector.matrix.android.internal.database.RealmKeysUtils
@ -36,6 +37,7 @@ import im.vector.matrix.android.internal.network.AccessTokenInterceptor
import im.vector.matrix.android.internal.network.RetrofitFactory
import im.vector.matrix.android.internal.network.interceptors.CurlLoggingInterceptor
import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater
import im.vector.matrix.android.internal.session.homeserver.DefaultHomeServerCapabilitiesService
import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater
import im.vector.matrix.android.internal.session.room.create.RoomCreateEventLiveObserver
import im.vector.matrix.android.internal.session.room.prune.EventsPruner
@ -178,4 +180,7 @@ internal abstract class SessionModule {
@Binds
abstract fun bindSecureStorageService(secureStorageService: DefaultSecureStorageService): SecureStorageService
@Binds
abstract fun bindHomeServerCapabilitiesService(homeServerCapabilitiesService: DefaultHomeServerCapabilitiesService): HomeServerCapabilitiesService
}

View file

@ -16,12 +16,12 @@
package im.vector.matrix.android.internal.session.content
import arrow.core.Try
import arrow.core.Try.Companion.raise
import com.squareup.moshi.Moshi
import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.internal.di.Authenticated
import im.vector.matrix.android.internal.network.ProgressRequestBody
import im.vector.matrix.android.internal.network.awaitResponse
import im.vector.matrix.android.internal.network.toFailure
import okhttp3.*
import java.io.File
import java.io.IOException
@ -37,28 +37,26 @@ internal class FileUploader @Inject constructor(@Authenticated
private val responseAdapter = moshi.adapter(ContentUploadResponse::class.java)
fun uploadFile(file: File,
filename: String?,
mimeType: String,
progressListener: ProgressRequestBody.Listener? = null): Try<ContentUploadResponse> {
suspend fun uploadFile(file: File,
filename: String?,
mimeType: String,
progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse {
val uploadBody = RequestBody.create(MediaType.parse(mimeType), file)
return upload(uploadBody, filename, progressListener)
}
fun uploadByteArray(byteArray: ByteArray,
filename: String?,
mimeType: String,
progressListener: ProgressRequestBody.Listener? = null): Try<ContentUploadResponse> {
suspend fun uploadByteArray(byteArray: ByteArray,
filename: String?,
mimeType: String,
progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse {
val uploadBody = RequestBody.create(MediaType.parse(mimeType), byteArray)
return upload(uploadBody, filename, progressListener)
}
private fun upload(uploadBody: RequestBody, filename: String?, progressListener: ProgressRequestBody.Listener?): Try<ContentUploadResponse> {
val urlBuilder = HttpUrl.parse(uploadUrl)?.newBuilder() ?: return raise(RuntimeException())
private suspend fun upload(uploadBody: RequestBody, filename: String?, progressListener: ProgressRequestBody.Listener?): ContentUploadResponse {
val urlBuilder = HttpUrl.parse(uploadUrl)?.newBuilder() ?: throw RuntimeException()
val httpUrl = urlBuilder
.addQueryParameter("filename", filename)
@ -71,19 +69,15 @@ internal class FileUploader @Inject constructor(@Authenticated
.post(requestBody)
.build()
return Try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException()
} else {
response.body()?.source()?.let {
responseAdapter.fromJson(it)
}
?: throw IOException()
return okHttpClient.newCall(request).awaitResponse().use { response ->
if (!response.isSuccessful) {
throw response.toFailure()
} else {
response.body()?.source()?.let {
responseAdapter.fromJson(it)
}
?: throw IOException()
}
}
}
}

View file

@ -93,32 +93,28 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
}
}
val contentUploadResponse = if (params.isRoomEncrypted) {
Timber.v("Encrypt thumbnail")
contentUploadStateTracker.setEncryptingThumbnail(eventId)
MXEncryptedAttachments.encryptAttachment(ByteArrayInputStream(thumbnailData.bytes), thumbnailData.mimeType)
.flatMap { encryptionResult ->
uploadedThumbnailEncryptedFileInfo = encryptionResult.encryptedFileInfo
try {
val contentUploadResponse = if (params.isRoomEncrypted) {
Timber.v("Encrypt thumbnail")
contentUploadStateTracker.setEncryptingThumbnail(eventId)
val encryptionResult = MXEncryptedAttachments.encryptAttachment(ByteArrayInputStream(thumbnailData.bytes), thumbnailData.mimeType)
uploadedThumbnailEncryptedFileInfo = encryptionResult.encryptedFileInfo
fileUploader.uploadByteArray(encryptionResult.encryptedByteArray,
"thumb_${attachment.name}",
"application/octet-stream",
thumbnailProgressListener)
} else {
fileUploader.uploadByteArray(thumbnailData.bytes,
"thumb_${attachment.name}",
thumbnailData.mimeType,
thumbnailProgressListener)
}
fileUploader
.uploadByteArray(encryptionResult.encryptedByteArray,
"thumb_${attachment.name}",
"application/octet-stream",
thumbnailProgressListener)
}
} else {
fileUploader
.uploadByteArray(thumbnailData.bytes,
"thumb_${attachment.name}",
thumbnailData.mimeType,
thumbnailProgressListener)
uploadedThumbnailUrl = contentUploadResponse.contentUri
} catch (t: Throwable) {
Timber.e(t)
return handleFailure(params, t)
}
contentUploadResponse
.fold(
{ Timber.e(it) },
{ uploadedThumbnailUrl = it.contentUri }
)
}
val progressListener = object : ProgressRequestBody.Listener {
@ -133,27 +129,26 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null
val contentUploadResponse = if (params.isRoomEncrypted) {
Timber.v("Encrypt file")
contentUploadStateTracker.setEncrypting(eventId)
return try {
val contentUploadResponse = if (params.isRoomEncrypted) {
Timber.v("Encrypt file")
contentUploadStateTracker.setEncrypting(eventId)
MXEncryptedAttachments.encryptAttachment(FileInputStream(attachmentFile), attachment.mimeType)
.flatMap { encryptionResult ->
uploadedFileEncryptedFileInfo = encryptionResult.encryptedFileInfo
val encryptionResult = MXEncryptedAttachments.encryptAttachment(FileInputStream(attachmentFile), attachment.mimeType)
uploadedFileEncryptedFileInfo = encryptionResult.encryptedFileInfo
fileUploader
.uploadByteArray(encryptionResult.encryptedByteArray, attachment.name, "application/octet-stream", progressListener)
}
} else {
fileUploader
.uploadFile(attachmentFile, attachment.name, attachment.mimeType, progressListener)
fileUploader
.uploadByteArray(encryptionResult.encryptedByteArray, attachment.name, "application/octet-stream", progressListener)
} else {
fileUploader
.uploadFile(attachmentFile, attachment.name, attachment.mimeType, progressListener)
}
handleSuccess(params, contentUploadResponse.contentUri, uploadedFileEncryptedFileInfo, uploadedThumbnailUrl, uploadedThumbnailEncryptedFileInfo)
} catch (t: Throwable) {
Timber.e(t)
handleFailure(params, t)
}
return contentUploadResponse
.fold(
{ handleFailure(params, it) },
{ handleSuccess(params, it.contentUri, uploadedFileEncryptedFileInfo, uploadedThumbnailUrl, uploadedThumbnailEncryptedFileInfo) }
)
}
private fun handleFailure(params: Params, failure: Throwable): Result {

View file

@ -0,0 +1,31 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.homeserver
import im.vector.matrix.android.internal.network.NetworkConstants
import retrofit2.Call
import retrofit2.http.GET
internal interface CapabilitiesAPI {
/**
* Request the upload capabilities
*/
@GET(NetworkConstants.URI_API_MEDIA_PREFIX_PATH_R0 + "config")
fun getUploadCapabilities(): Call<GetUploadCapabilitiesResult>
}

View file

@ -0,0 +1,75 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.homeserver
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilities
import im.vector.matrix.android.internal.database.model.HomeServerCapabilitiesEntity
import im.vector.matrix.android.internal.database.query.getOrCreate
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.awaitTransaction
import java.util.*
import javax.inject.Inject
internal interface GetHomeServerCapabilitiesTask : Task<Unit, Unit>
internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
private val capabilitiesAPI: CapabilitiesAPI,
private val monarchy: Monarchy
) : GetHomeServerCapabilitiesTask {
override suspend fun execute(params: Unit) {
var doRequest = false
monarchy.awaitTransaction { realm ->
val homeServerCapabilitiesEntity = HomeServerCapabilitiesEntity.getOrCreate(realm)
doRequest = homeServerCapabilitiesEntity.lastUpdatedTimestamp + MIN_DELAY_BETWEEN_TWO_REQUEST_MILLIS < Date().time
}
if (!doRequest) {
return
}
val uploadCapabilities = executeRequest<GetUploadCapabilitiesResult> {
apiCall = capabilitiesAPI.getUploadCapabilities()
}
// TODO Add other call here (get version, etc.)
insertInDb(uploadCapabilities)
}
private fun insertInDb(getUploadCapabilitiesResult: GetUploadCapabilitiesResult) {
monarchy
.writeAsync { realm ->
val homeServerCapabilitiesEntity = HomeServerCapabilitiesEntity.getOrCreate(realm)
homeServerCapabilitiesEntity.maxUploadFileSize = getUploadCapabilitiesResult.maxUploadSize
?: HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN
homeServerCapabilitiesEntity.lastUpdatedTimestamp = Date().time
}
}
companion object {
// 8 hours like on Riot Web
private const val MIN_DELAY_BETWEEN_TWO_REQUEST_MILLIS = 8 * 60 * 60 * 1000
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.homeserver
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilities
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilitiesService
import im.vector.matrix.android.internal.database.mapper.HomeServerCapabilitiesMapper
import im.vector.matrix.android.internal.database.model.HomeServerCapabilitiesEntity
import im.vector.matrix.android.internal.database.query.getOrCreate
import javax.inject.Inject
internal class DefaultHomeServerCapabilitiesService @Inject constructor(private val monarchy: Monarchy) : HomeServerCapabilitiesService {
override fun getHomeServerCapabilities(): HomeServerCapabilities {
var entity: HomeServerCapabilitiesEntity? = null
monarchy.doWithRealm { realm ->
entity = HomeServerCapabilitiesEntity.getOrCreate(realm)
}
return with(entity) {
if (this != null) {
HomeServerCapabilitiesMapper.map(this)
} else {
// Should not happen
HomeServerCapabilities(HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN)
}
}
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.homeserver
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GetUploadCapabilitiesResult(
/**
* The maximum size an upload can be in bytes. Clients SHOULD use this as a guide when uploading content.
* If not listed or null, the size limit should be treated as unknown.
*/
@Json(name = "m.upload.size")
val maxUploadSize: Long? = null
)

View file

@ -0,0 +1,41 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.homeserver
import dagger.Binds
import dagger.Module
import dagger.Provides
import im.vector.matrix.android.internal.session.SessionScope
import retrofit2.Retrofit
@Module
internal abstract class HomeServerCapabilitiesModule {
@Module
companion object {
@Provides
@JvmStatic
@SessionScope
fun providesCapabilitiesAPI(retrofit: Retrofit): CapabilitiesAPI {
return retrofit.create(CapabilitiesAPI::class.java)
}
}
@Binds
abstract fun bindGetHomeServerCapabilitiesTask(getHomeServerCapabilitiesTask: DefaultGetHomeServerCapabilitiesTask): GetHomeServerCapabilitiesTask
}

View file

@ -16,7 +16,6 @@
package im.vector.matrix.android.internal.session.sync
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.R
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.failure.MatrixError
@ -25,6 +24,7 @@ import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.DefaultInitialSyncProgressService
import im.vector.matrix.android.internal.session.filter.FilterRepository
import im.vector.matrix.android.internal.session.homeserver.GetHomeServerCapabilitiesTask
import im.vector.matrix.android.internal.session.sync.model.SyncResponse
import im.vector.matrix.android.internal.task.Task
import javax.inject.Inject
@ -42,11 +42,14 @@ internal class DefaultSyncTask @Inject constructor(private val syncAPI: SyncAPI,
private val sessionParamsStore: SessionParamsStore,
private val initialSyncProgressService: DefaultInitialSyncProgressService,
private val syncTokenStore: SyncTokenStore,
private val monarchy: Monarchy
private val getHomeServerCapabilitiesTask: GetHomeServerCapabilitiesTask
) : SyncTask {
override suspend fun execute(params: SyncTask.Params) {
// Maybe refresh the home server capabilities data we know
getHomeServerCapabilitiesTask.execute(Unit)
val requestParams = HashMap<String, String>()
var timeout = 0L
val token = syncTokenStore.getLastToken()

View file

@ -149,4 +149,10 @@ android\.app\.AlertDialog
new Gson\(\)
### Use matrixOneTimeWorkRequestBuilder
import androidx.work.OneTimeWorkRequestBuilder===1
import androidx.work.OneTimeWorkRequestBuilder===1
### Use TextUtils.formatFileSize
Formatter\.formatFileSize===1
### Use TextUtils.formatFileSize with short format param to true
Formatter\.formatShortFileSize===1

View file

@ -24,6 +24,8 @@ import java.io.File
// Implementation should return true in case of success
typealias ActionOnFile = (file: File) -> Boolean
internal fun String?.isLocalFile() = this != null && File(this).exists()
/* ==========================================================================================
* Delete
* ========================================================================================== */

View file

@ -16,6 +16,9 @@
package im.vector.riotx.core.utils
import android.content.Context
import android.os.Build
import android.text.format.Formatter
import java.util.*
object TextUtils {
@ -42,4 +45,28 @@ object TextUtils {
return value.toString()
}
}
/**
* Since Android O, the system considers that 1ko = 1000 bytes instead of 1024 bytes. We want to avoid that for the moment.
*/
fun formatFileSize(context: Context, sizeBytes: Long, useShortFormat: Boolean = false): String {
val normalizedSize = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) {
sizeBytes
} else {
// First convert the size
when {
sizeBytes < 1024 -> sizeBytes
sizeBytes < 1024 * 1024 -> sizeBytes * 1000 / 1024
sizeBytes < 1024 * 1024 * 1024 -> sizeBytes * 1000 / 1024 * 1000 / 1024
else -> sizeBytes * 1000 / 1024 * 1000 / 1024 * 1000 / 1024
}
}
return if (useShortFormat) {
Formatter.formatShortFileSize(context, normalizedSize)
} else {
Formatter.formatFileSize(context, normalizedSize)
}
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail
data class FileTooBigError(
val filename: String,
val fileSizeInBytes: Long,
val homeServerLimitInBytes: Long
)

View file

@ -27,7 +27,6 @@ import android.os.Bundle
import android.os.Parcelable
import android.text.Editable
import android.text.Spannable
import android.text.TextUtils
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
@ -148,18 +147,15 @@ class RoomDetailFragment :
* @param displayName the display name to sanitize
* @return the sanitized display name
*/
fun sanitizeDisplayname(displayName: String): String? {
// sanity checks
if (!TextUtils.isEmpty(displayName)) {
val ircPattern = " (IRC)"
if (displayName.endsWith(ircPattern)) {
return displayName.substring(0, displayName.length - ircPattern.length)
}
private fun sanitizeDisplayName(displayName: String): String {
if (displayName.endsWith(ircPattern)) {
return displayName.substring(0, displayName.length - ircPattern.length)
}
return displayName
}
private const val ircPattern = " (IRC)"
}
private val roomDetailArgs: RoomDetailArgs by args()
@ -227,6 +223,10 @@ class RoomDetailFragment :
scrollOnHighlightedEventCallback.scheduleScrollTo(it)
}
roomDetailViewModel.fileTooBigEvent.observeEvent(this) {
displayFileTooBigWarning(it)
}
roomDetailViewModel.selectSubscribe(this, RoomDetailViewState::tombstoneEventHandling, uniqueOnly("tombstoneEventHandling")) {
renderTombstoneEventHandling(it)
}
@ -254,6 +254,18 @@ class RoomDetailFragment :
}
}
private fun displayFileTooBigWarning(error: FileTooBigError) {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.dialog_title_error)
.setMessage(getString(R.string.error_file_too_big,
error.filename,
TextUtils.formatFileSize(requireContext(), error.fileSizeInBytes),
TextUtils.formatFileSize(requireContext(), error.homeServerLimitInBytes)
))
.setPositiveButton(R.string.ok, null)
.show()
}
private fun setupNotificationView() {
notificationAreaView.delegate = object : NotificationAreaView.Delegate {
@ -970,23 +982,23 @@ class RoomDetailFragment :
// var vibrate = false
val myDisplayName = session.getUser(session.myUserId)?.displayName
if (TextUtils.equals(myDisplayName, text)) {
if (myDisplayName == text) {
// current user
if (TextUtils.isEmpty(composerLayout.composerEditText.text)) {
if (composerLayout.composerEditText.text.isBlank()) {
composerLayout.composerEditText.append(Command.EMOTE.command + " ")
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
// vibrate = true
}
} else {
// another user
if (TextUtils.isEmpty(composerLayout.composerEditText.text)) {
if (composerLayout.composerEditText.text.isBlank()) {
// Ensure displayName will not be interpreted as a Slash command
if (text.startsWith("/")) {
composerLayout.composerEditText.append("\\")
}
composerLayout.composerEditText.append(sanitizeDisplayname(text)!! + ": ")
composerLayout.composerEditText.append(sanitizeDisplayName(text) + ": ")
} else {
composerLayout.composerEditText.text.insert(composerLayout.composerEditText.selectionStart, sanitizeDisplayname(text)!! + " ")
composerLayout.composerEditText.text.insert(composerLayout.composerEditText.selectionStart, sanitizeDisplayName(text) + " ")
}
// vibrate = true

View file

@ -37,6 +37,7 @@ import im.vector.matrix.android.api.session.events.model.isImageMessage
import im.vector.matrix.android.api.session.events.model.isTextMessage
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilities
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
@ -48,6 +49,7 @@ import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
import im.vector.matrix.rx.rx
import im.vector.riotx.BuildConfig
import im.vector.riotx.R
import im.vector.riotx.core.extensions.postLiveEvent
import im.vector.riotx.core.intent.getFilenameFromUri
@ -227,23 +229,22 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
val navigateToEvent: LiveData<LiveEvent<String>>
get() = _navigateToEvent
private val _fileTooBigEvent = MutableLiveData<LiveEvent<FileTooBigError>>()
val fileTooBigEvent: LiveData<LiveEvent<FileTooBigError>>
get() = _fileTooBigEvent
private val _downloadedFileEvent = MutableLiveData<LiveEvent<DownloadFileState>>()
val downloadedFileEvent: LiveData<LiveEvent<DownloadFileState>>
get() = _downloadedFileEvent
fun isMenuItemVisible(@IdRes itemId: Int): Boolean {
if (itemId == R.id.clear_message_queue) {
//For now always disable, woker cancellation is not working properly
return false//timeline.pendingEventCount() > 0
}
if (itemId == R.id.resend_all) {
return timeline.failedToDeliverEventCount() > 0
}
if (itemId == R.id.clear_all) {
return timeline.failedToDeliverEventCount() > 0
}
return false
fun isMenuItemVisible(@IdRes itemId: Int) = when (itemId) {
R.id.clear_message_queue ->
/* For now always disable on production, worker cancellation is not working properly */
timeline.pendingEventCount() > 0 && BuildConfig.DEBUG
R.id.resend_all -> timeline.failedToDeliverEventCount() > 0
R.id.clear_all -> timeline.failedToDeliverEventCount() > 0
else -> false
}
// PRIVATE METHODS *****************************************************************************
@ -470,7 +471,20 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
type = ContentAttachmentData.Type.values()[it.mediaType]
)
}
room.sendMedias(attachments)
val homeServerCapabilities = session.getHomeServerCapabilities()
val maxUploadFileSize = homeServerCapabilities.maxUploadFileSize
if (maxUploadFileSize == HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN) {
// Unknown limitation
room.sendMedias(attachments)
} else {
when (val tooBigFile = attachments.find { it.size > maxUploadFileSize }) {
null -> room.sendMedias(attachments)
else -> _fileTooBigEvent.postValue(LiveEvent(FileTooBigError(tooBigFile.name ?: tooBigFile.path, tooBigFile.size, maxUploadFileSize)))
}
}
}
private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) {

View file

@ -42,6 +42,7 @@ import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.core.utils.DimensionConverter
import im.vector.riotx.core.utils.containsOnlyEmojis
import im.vector.riotx.core.utils.isLocalFile
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
@ -117,6 +118,8 @@ class MessageItemFactory @Inject constructor(
.avatarRenderer(avatarRenderer)
.colorProvider(colorProvider)
.dimensionConverter(dimensionConverter)
.izLocalFile(messageContent.getFileUrl().isLocalFile())
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.informationData(informationData)
.highlighted(highlight)
.avatarCallback(callback)
@ -147,6 +150,8 @@ class MessageItemFactory @Inject constructor(
.avatarRenderer(avatarRenderer)
.colorProvider(colorProvider)
.dimensionConverter(dimensionConverter)
.izLocalFile(messageContent.getFileUrl().isLocalFile())
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.informationData(informationData)
.highlighted(highlight)
.avatarCallback(callback)

View file

@ -16,7 +16,6 @@
package im.vector.riotx.features.home.room.detail.timeline.helper
import android.text.format.Formatter
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
@ -26,23 +25,25 @@ import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.riotx.R
import im.vector.riotx.core.di.ActiveSessionHolder
import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.features.media.ImageContentRenderer
import im.vector.riotx.core.utils.TextUtils
import im.vector.riotx.features.ui.getMessageTextColor
import javax.inject.Inject
class ContentUploadStateTrackerBinder @Inject constructor(private val activeSessionHolder: ActiveSessionHolder,
private val colorProvider: ColorProvider) {
private val colorProvider: ColorProvider,
private val errorFormatter: ErrorFormatter) {
private val updateListeners = mutableMapOf<String, ContentUploadStateTracker.UpdateListener>()
fun bind(eventId: String,
mediaData: ImageContentRenderer.Data,
isLocalFile: Boolean,
progressLayout: ViewGroup) {
activeSessionHolder.getActiveSession().also { session ->
val uploadStateTracker = session.contentUploadProgressTracker()
val updateListener = ContentMediaProgressUpdater(progressLayout, mediaData, colorProvider)
val updateListener = ContentMediaProgressUpdater(progressLayout, isLocalFile, colorProvider, errorFormatter)
updateListeners[eventId] = updateListener
uploadStateTracker.track(eventId, updateListener)
}
@ -60,8 +61,9 @@ class ContentUploadStateTrackerBinder @Inject constructor(private val activeSess
}
private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
private val mediaData: ImageContentRenderer.Data,
private val colorProvider: ColorProvider) : ContentUploadStateTracker.UpdateListener {
private val isLocalFile: Boolean,
private val colorProvider: ColorProvider,
private val errorFormatter: ErrorFormatter) : ContentUploadStateTracker.UpdateListener {
override fun onUpdate(state: ContentUploadStateTracker.State) {
when (state) {
@ -76,7 +78,7 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
}
private fun handleIdle(state: ContentUploadStateTracker.State.Idle) {
if (mediaData.isLocalFile()) {
if (isLocalFile) {
progressLayout.isVisible = true
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
@ -124,8 +126,8 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
progressBar?.isIndeterminate = false
progressBar?.progress = percent.toInt()
progressTextView?.text = progressLayout.context.getString(resId,
Formatter.formatShortFileSize(progressLayout.context, current),
Formatter.formatShortFileSize(progressLayout.context, total))
TextUtils.formatFileSize(progressLayout.context, current, true),
TextUtils.formatFileSize(progressLayout.context, total, true))
progressTextView?.setTextColor(colorProvider.getMessageTextColor(SendState.SENDING))
}
@ -134,7 +136,7 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
progressBar?.isVisible = false
progressTextView?.text = state.throwable.localizedMessage
progressTextView?.text = errorFormatter.toHumanReadable(state.throwable)
progressTextView?.setTextColor(colorProvider.getMessageTextColor(SendState.UNDELIVERED))
}

View file

@ -111,8 +111,6 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
holder.timeView.text = informationData.time
holder.memberNameView.text = informationData.memberName
avatarRenderer.render(informationData.avatarUrl, informationData.senderId, informationData.memberName?.toString(), holder.avatarImageView)
holder.view.setOnClickListener(cellClickListener)
holder.view.setOnLongClickListener(longClickListener)
holder.avatarImageView.setOnLongClickListener(longClickListener)
holder.memberNameView.setOnLongClickListener(longClickListener)
} else {
@ -121,12 +119,13 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
holder.avatarImageView.visibility = View.GONE
holder.memberNameView.visibility = View.GONE
holder.timeView.visibility = View.GONE
holder.view.setOnClickListener(null)
holder.view.setOnLongClickListener(null)
holder.avatarImageView.setOnLongClickListener(null)
holder.memberNameView.setOnLongClickListener(null)
}
holder.view.setOnClickListener(cellClickListener)
holder.view.setOnLongClickListener(longClickListener)
holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener)
if (!shouldShowReactionAtBottom() || informationData.orderedReactionList.isNullOrEmpty()) {

View file

@ -22,9 +22,11 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
@ -36,19 +38,35 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
var iconRes: Int = 0
@EpoxyAttribute
var clickListener: View.OnClickListener? = null
@EpoxyAttribute
var izLocalFile = false
@EpoxyAttribute
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.fileLayout, holder.filenameView)
if (!informationData.sendState.hasFailed()) {
contentUploadStateTrackerBinder.bind(informationData.eventId, izLocalFile, holder.progressLayout)
} else {
holder.progressLayout.isVisible = false
}
holder.filenameView.text = filename
holder.fileImageView.setImageResource(iconRes)
holder.filenameView.setOnClickListener(clickListener)
holder.filenameView.paintFlags = (holder.filenameView.paintFlags or Paint.UNDERLINE_TEXT_FLAG)
}
override fun unbind(holder: Holder) {
super.unbind(holder)
contentUploadStateTrackerBinder.unbind(informationData.eventId)
}
override fun getViewType() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) {
val progressLayout by bind<ViewGroup>(R.id.messageFileUploadProgressLayout)
val fileLayout by bind<ViewGroup>(R.id.messageFileLayout)
val fileImageView by bind<ImageView>(R.id.messageFileImageView)
val filenameView by bind<TextView>(R.id.messageFilenameView)

View file

@ -20,6 +20,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.view.ViewCompat
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R
@ -44,11 +45,13 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
super.bind(holder)
imageContentRenderer.render(mediaData, ImageContentRenderer.Mode.THUMBNAIL, holder.imageView)
if (!informationData.sendState.hasFailed()) {
contentUploadStateTrackerBinder.bind(informationData.eventId, mediaData, holder.progressLayout)
contentUploadStateTrackerBinder.bind(informationData.eventId, mediaData.isLocalFile(), holder.progressLayout)
} else {
holder.progressLayout.isVisible = false
}
holder.imageView.setOnClickListener(clickListener)
holder.imageView.setOnLongClickListener(longClickListener)
ViewCompat.setTransitionName(holder.imageView,"imagePreview_${id()}")
ViewCompat.setTransitionName(holder.imageView, "imagePreview_${id()}")
holder.mediaContentView.setOnClickListener(cellClickListener)
holder.mediaContentView.setOnLongClickListener(longClickListener)
// The sending state color will be apply to the progress text

View file

@ -33,9 +33,9 @@ import im.vector.riotx.core.di.ActiveSessionHolder
import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.glide.GlideRequest
import im.vector.riotx.core.utils.DimensionConverter
import im.vector.riotx.core.utils.isLocalFile
import kotlinx.android.parcel.Parcelize
import timber.log.Timber
import java.io.File
import javax.inject.Inject
class ImageContentRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder,
@ -54,9 +54,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
val rotation: Int? = null
) : Parcelable {
fun isLocalFile(): Boolean {
return url != null && File(url).exists()
}
fun isLocalFile() = url.isLocalFile()
}
enum class Mode {

View file

@ -20,7 +20,7 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.text.Editable
import android.text.TextUtils
import android.util.Patterns
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
@ -171,7 +171,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
MXSession.getApplicationSizeCaches(activity, object : SimpleApiCallback<Long>() {
override fun onSuccess(size: Long) {
if (null != activity) {
it.summary = android.text.format.Formatter.formatFileSize(activity, size)
it.summary = TextUtils.formatFileSize(activity, size)
}
}
})
@ -189,7 +189,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
val size = getSizeOfFiles(requireContext(),
File(requireContext().cacheDir, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR))
it.summary = android.text.format.Formatter.formatFileSize(activity, size.toLong())
it.summary = TextUtils.formatFileSize(requireContext(), size.toLong())
it.onPreferenceClickListener = Preference.OnPreferenceClickListener {
GlobalScope.launch(Dispatchers.Main) {
@ -208,7 +208,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
File(requireContext().cacheDir, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR))
}
it.summary = android.text.format.Formatter.formatFileSize(activity, newSize.toLong())
it.summary = TextUtils.formatFileSize(requireContext(), newSize.toLong())
hideLoadingView()
}
@ -534,7 +534,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
private fun addEmail(email: String) {
// check first if the email syntax is valid
// if email is null , then also its invalid email
if (TextUtils.isEmpty(email) || !android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
if (email.isBlank() || !Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
activity?.toast(R.string.auth_invalid_email)
return
}
@ -719,9 +719,9 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
val newPwd = newPasswordText.text.toString().trim()
val newConfirmPwd = confirmNewPasswordText.text.toString().trim()
updateButton.isEnabled = oldPwd.isNotEmpty() && newPwd.isNotEmpty() && TextUtils.equals(newPwd, newConfirmPwd)
updateButton.isEnabled = oldPwd.isNotEmpty() && newPwd.isNotEmpty() && newPwd == newConfirmPwd
if (newPwd.isNotEmpty() && newConfirmPwd.isNotEmpty() && !TextUtils.equals(newPwd, newConfirmPwd)) {
if (newPwd.isNotEmpty() && newConfirmPwd.isNotEmpty() && newPwd != newConfirmPwd) {
confirmNewPasswordTil.error = getString(R.string.passwords_do_not_match)
}
}

View file

@ -2,66 +2,68 @@
<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:id="@+id/messageFileLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<LinearLayout
android:id="@+id/messageFileLayout"
<ImageView
android:id="@+id/messageFilee2eIcon"
android:layout_width="14dp"
android:layout_height="14dp"
android:src="@drawable/e2e_verified"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<!-- the media type -->
<ImageView
android:id="@+id/messageFileImageView"
android:layout_width="@dimen/chat_avatar_size"
android:layout_height="@dimen/chat_avatar_size"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
app:layout_constraintStart_toEndOf="@+id/messageFilee2eIcon"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/filetype_image" />
<!-- the media -->
<TextView
android:id="@+id/messageFilenameView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:layout_marginEnd="32dp"
android:layout_marginRight="32dp"
android:layout_marginBottom="8dp"
android:duplicateParentState="true"
android:orientation="horizontal"
android:autoLink="none"
android:gravity="center_vertical"
android:minHeight="@dimen/chat_avatar_size"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
app:layout_constraintStart_toEndOf="@+id/messageFileImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="A filename here" />
<ImageView
android:id="@+id/messageFilee2eIcon"
android:layout_width="14dp"
android:layout_height="14dp"
android:src="@drawable/e2e_verified"
android:visibility="gone" />
<!-- the media type -->
<ImageView
android:id="@+id/messageFileImageView"
android:layout_width="@dimen/chat_avatar_size"
android:layout_height="@dimen/chat_avatar_size"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:src="@drawable/filetype_image" />
<!-- the media -->
<TextView
android:id="@+id/messageFilenameView"
android:layout_width="0dp"
android:layout_height="@dimen/chat_avatar_size"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:layout_weight="1"
android:autoLink="none"
android:gravity="center_vertical"
tools:text="A filename here" />
</LinearLayout>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/horizontalBarrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="messageFileImageView,messageFilenameView" />
<include
android:id="@+id/messageMediaUploadProgressLayout"
android:id="@+id/messageFileUploadProgressLayout"
layout="@layout/media_upload_download_progress_layout"
android:layout_width="0dp"
android:layout_height="46dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="32dp"
android:layout_marginRight="32dp"
android:layout_marginBottom="8dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageFileLayout"
app:layout_constraintTop_toBottomOf="@+id/horizontalBarrier"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -22,7 +22,7 @@
<item
android:id="@+id/clear_message_queue"
android:title="@string/clear_timeline_send_queue"
android:visible="false"
android:visible="@bool/debug_mode"
app:showAsAction="never"
tools:visible="true" />

View file

@ -22,4 +22,6 @@
<string name="a11y_create_room">Create a new room</string>
<string name="a11y_close_keys_backup_banner">Close keys backup banner</string>
<string name="error_file_too_big">"The file '%1$s' (%2$s) is too large to upload. The limit is %3$s."</string>
</resources>