Merge branch 'develop' into feature/state_service_coroutines

This commit is contained in:
Benoit Marty 2020-12-11 16:54:41 +01:00 committed by GitHub
commit 5b74eb3bca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
113 changed files with 2175 additions and 689 deletions

View file

@ -24,6 +24,8 @@
<w>pbkdf</w> <w>pbkdf</w>
<w>pids</w> <w>pids</w>
<w>pkcs</w> <w>pkcs</w>
<w>previewable</w>
<w>previewables</w>
<w>riotx</w> <w>riotx</w>
<w>signin</w> <w>signin</w>
<w>signout</w> <w>signout</w>

View file

@ -4,30 +4,40 @@ Changes in Element 1.0.12 (2020-XX-XX)
Features ✨: Features ✨:
- Add room aliases management, and room directory visibility management in a dedicated screen (#1579, #2428) - Add room aliases management, and room directory visibility management in a dedicated screen (#1579, #2428)
- Room setting: update join rules and guest access (#2442) - Room setting: update join rules and guest access (#2442)
- Url preview (#481)
- Store encrypted file in cache and cleanup decrypted file at each app start (#2512)
- Emoji Keyboard (#2520)
Improvements 🙌: Improvements 🙌:
- Add Setting Item to Change PIN (#2462) - Add Setting Item to Change PIN (#2462)
- Improve room history visibility setting UX (#1579) - Improve room history visibility setting UX (#1579)
Bugfix 🐛: Bugfix 🐛:
- Fix cancellation of sending event (#2438)
- Double bottomsheet effect after verify with passphrase - Double bottomsheet effect after verify with passphrase
- EditText cursor jumps to the start while typing fast (#2469) - EditText cursor jumps to the start while typing fast (#2469)
- Show preview when sending attachment from the keyboard (#2440)
- Do not compress GIFs (#1616, #1254)
Translations 🗣: Translations 🗣:
- -
SDK API changes ⚠️: SDK API changes ⚠️:
- StateService now exposes suspendable function instead of using MatrixCallback. - StateService now exposes suspendable function instead of using MatrixCallback.
- RawCacheStrategy has been moved and renamed to CacheStrategy
- FileService: remove useless FileService.DownloadMode
Build 🧱: Build 🧱:
- Upgrade some dependencies and Kotlin version - Upgrade some dependencies and Kotlin version
- Use fragment-ktx and preference-ktx dependencies (fix lint issue KtxExtensionAvailable) - Use fragment-ktx and preference-ktx dependencies (fix lint issue KtxExtensionAvailable)
- Upgrade Realm dependency to 10.1.2
Test: Test:
- -
Other changes: Other changes:
- Remove "Status.im" theme #2424 - Remove "Status.im" theme #2424
- Log HTTP requests and responses in production (level BASIC, i.e. without any private data)
Changes in Element 1.0.11 (2020-11-27) Changes in Element 1.0.11 (2020-11-27)
=================================================== ===================================================

View file

@ -18,7 +18,7 @@ org.gradle.jvmargs=-Xmx2048m
org.gradle.vfs.watch=true org.gradle.vfs.watch=true
vector.debugPrivateData=false vector.debugPrivateData=false
vector.httpLogLevel=NONE vector.httpLogLevel=BASIC
# Note: to debug, you can put and uncomment the following lines in the file ~/.gradle/gradle.properties to override the value above # Note: to debug, you can put and uncomment the following lines in the file ~/.gradle/gradle.properties to override the value above
#vector.debugPrivateData=true #vector.debugPrivateData=true

View file

@ -9,7 +9,7 @@ buildscript {
jcenter() jcenter()
} }
dependencies { dependencies {
classpath "io.realm:realm-gradle-plugin:10.0.0" classpath "io.realm:realm-gradle-plugin:10.1.2"
} }
} }
@ -63,7 +63,7 @@ android {
release { release {
buildConfigField "boolean", "LOG_PRIVATE_DATA", "false" buildConfigField "boolean", "LOG_PRIVATE_DATA", "false"
buildConfigField "okhttp3.logging.HttpLoggingInterceptor.Level", "OKHTTP_LOGGING_LEVEL", "okhttp3.logging.HttpLoggingInterceptor.Level.NONE" buildConfigField "okhttp3.logging.HttpLoggingInterceptor.Level", "OKHTTP_LOGGING_LEVEL", "okhttp3.logging.HttpLoggingInterceptor.Level.BASIC"
} }
} }

View file

@ -264,7 +264,7 @@ class KeysBackupTest : InstrumentedTest {
assertNotNull(decryption) assertNotNull(decryption)
// - Check decryptKeyBackupData() returns stg // - Check decryptKeyBackupData() returns stg
val sessionData = keysBackup val sessionData = keysBackup
.decryptKeyBackupData(keyBackupData!!, .decryptKeyBackupData(keyBackupData,
session.olmInboundGroupSession!!.sessionIdentifier(), session.olmInboundGroupSession!!.sessionIdentifier(),
cryptoTestData.roomId, cryptoTestData.roomId,
decryption!!) decryption!!)

View file

@ -111,7 +111,7 @@ class KeysBackupTestHelper(
Assert.assertTrue(keysBackup.isEnabled) Assert.assertTrue(keysBackup.isEnabled)
stateObserver.stopAndCheckStates(null) stateObserver.stopAndCheckStates(null)
return PrepareKeysBackupDataResult(megolmBackupCreationInfo, keysVersion.version!!) return PrepareKeysBackupDataResult(megolmBackupCreationInfo, keysVersion.version)
} }
/** /**

View file

@ -0,0 +1,108 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.media
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.junit.runner.RunWith
import org.matrix.android.sdk.InstrumentedTest
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
@RunWith(AndroidJUnit4::class)
internal class UrlsExtractorTest : InstrumentedTest {
private val urlsExtractor = UrlsExtractor()
@Test
fun wrongEventTypeTest() {
createEvent(body = "https://matrix.org")
.copy(type = EventType.STATE_ROOM_GUEST_ACCESS)
.let { urlsExtractor.extract(it) }
.size shouldBeEqualTo 0
}
@Test
fun oneUrlTest() {
createEvent(body = "https://matrix.org")
.let { urlsExtractor.extract(it) }
.let { result ->
result.size shouldBeEqualTo 1
result[0] shouldBeEqualTo "https://matrix.org"
}
}
@Test
fun withoutProtocolTest() {
createEvent(body = "www.matrix.org")
.let { urlsExtractor.extract(it) }
.size shouldBeEqualTo 0
}
@Test
fun oneUrlWithParamTest() {
createEvent(body = "https://matrix.org?foo=bar")
.let { urlsExtractor.extract(it) }
.let { result ->
result.size shouldBeEqualTo 1
result[0] shouldBeEqualTo "https://matrix.org?foo=bar"
}
}
@Test
fun oneUrlWithParamsTest() {
createEvent(body = "https://matrix.org?foo=bar&bar=foo")
.let { urlsExtractor.extract(it) }
.let { result ->
result.size shouldBeEqualTo 1
result[0] shouldBeEqualTo "https://matrix.org?foo=bar&bar=foo"
}
}
@Test
fun oneUrlInlinedTest() {
createEvent(body = "Hello https://matrix.org, how are you?")
.let { urlsExtractor.extract(it) }
.let { result ->
result.size shouldBeEqualTo 1
result[0] shouldBeEqualTo "https://matrix.org"
}
}
@Test
fun twoUrlsTest() {
createEvent(body = "https://matrix.org https://example.org")
.let { urlsExtractor.extract(it) }
.let { result ->
result.size shouldBeEqualTo 2
result[0] shouldBeEqualTo "https://matrix.org"
result[1] shouldBeEqualTo "https://example.org"
}
}
private fun createEvent(body: String): Event = Event(
type = EventType.MESSAGE,
content = MessageTextContent(
msgType = MessageType.MSGTYPE_TEXT,
body = body
).toContent()
)
}

View file

@ -17,7 +17,6 @@
package org.matrix.android.sdk.internal.network.interceptors package org.matrix.android.sdk.internal.network.interceptors
import androidx.annotation.NonNull import androidx.annotation.NonNull
import org.matrix.android.sdk.BuildConfig
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
@ -38,31 +37,28 @@ class FormattedJsonHttpLogger : HttpLoggingInterceptor.Logger {
*/ */
@Synchronized @Synchronized
override fun log(@NonNull message: String) { override fun log(@NonNull message: String) {
// In RELEASE there is no log, but for sure, test again BuildConfig.DEBUG Timber.v(message)
if (BuildConfig.DEBUG) {
Timber.v(message)
if (message.startsWith("{")) { if (message.startsWith("{")) {
// JSON Detected // JSON Detected
try { try {
val o = JSONObject(message) val o = JSONObject(message)
logJson(o.toString(INDENT_SPACE)) logJson(o.toString(INDENT_SPACE))
} catch (e: JSONException) { } catch (e: JSONException) {
// Finally this is not a JSON string... // Finally this is not a JSON string...
Timber.e(e) Timber.e(e)
} }
} else if (message.startsWith("[")) { } else if (message.startsWith("[")) {
// JSON Array detected // JSON Array detected
try { try {
val o = JSONArray(message) val o = JSONArray(message)
logJson(o.toString(INDENT_SPACE)) logJson(o.toString(INDENT_SPACE))
} catch (e: JSONException) { } catch (e: JSONException) {
// Finally not JSON... // Finally not JSON...
Timber.e(e) Timber.e(e)
}
} }
// Else not a json string to log
} }
// Else not a json string to log
} }
private fun logJson(formattedJson: String) { private fun logJson(formattedJson: String) {

View file

@ -14,16 +14,16 @@
* limitations under the License. * limitations under the License.
*/ */
package org.matrix.android.sdk.api.raw package org.matrix.android.sdk.api.cache
sealed class RawCacheStrategy { sealed class CacheStrategy {
// Data is always fetched from the server // Data is always fetched from the server
object NoCache : RawCacheStrategy() object NoCache : CacheStrategy()
// Once data is retrieved, it is stored for the provided amount of time. // Once data is retrieved, it is stored for the provided amount of time.
// In case of error, and if strict is set to false, the cache can be returned if available // In case of error, and if strict is set to false, the cache can be returned if available
data class TtlCache(val validityDurationInMillis: Long, val strict: Boolean) : RawCacheStrategy() data class TtlCache(val validityDurationInMillis: Long, val strict: Boolean) : CacheStrategy()
// Once retrieved, the data is stored in cache and will be always get from the cache // Once retrieved, the data is stored in cache and will be always get from the cache
object InfiniteCache : RawCacheStrategy() object InfiniteCache : CacheStrategy()
} }

View file

@ -16,6 +16,8 @@
package org.matrix.android.sdk.api.raw package org.matrix.android.sdk.api.raw
import org.matrix.android.sdk.api.cache.CacheStrategy
/** /**
* Useful methods to fetch raw data from the server. The access token will not be used to fetched the data * Useful methods to fetch raw data from the server. The access token will not be used to fetched the data
*/ */
@ -23,7 +25,7 @@ interface RawService {
/** /**
* Get a URL, either from cache or from the remote server, depending on the cache strategy * Get a URL, either from cache or from the remote server, depending on the cache strategy
*/ */
suspend fun getUrl(url: String, rawCacheStrategy: RawCacheStrategy): String suspend fun getUrl(url: String, cacheStrategy: CacheStrategy): String
/** /**
* Specific case for the well-known file. Cache validity is 8 hours * Specific case for the well-known file. Cache validity is 8 hours

View file

@ -35,6 +35,7 @@ import org.matrix.android.sdk.api.session.group.GroupService
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
import org.matrix.android.sdk.api.session.identity.IdentityService import org.matrix.android.sdk.api.session.identity.IdentityService
import org.matrix.android.sdk.api.session.integrationmanager.IntegrationManagerService import org.matrix.android.sdk.api.session.integrationmanager.IntegrationManagerService
import org.matrix.android.sdk.api.session.media.MediaService
import org.matrix.android.sdk.api.session.permalinks.PermalinkService import org.matrix.android.sdk.api.session.permalinks.PermalinkService
import org.matrix.android.sdk.api.session.profile.ProfileService import org.matrix.android.sdk.api.session.profile.ProfileService
import org.matrix.android.sdk.api.session.pushers.PushersService import org.matrix.android.sdk.api.session.pushers.PushersService
@ -181,6 +182,11 @@ interface Session :
*/ */
fun widgetService(): WidgetService fun widgetService(): WidgetService
/**
* Returns the media service associated with the session
*/
fun mediaService(): MediaService
/** /**
* Returns the integration manager service associated with the session * Returns the integration manager service associated with the session
*/ */

View file

@ -21,6 +21,7 @@ import android.os.Parcelable
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import org.matrix.android.sdk.api.util.MimeTypes.normalizeMimeType
@Parcelize @Parcelize
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@ -45,5 +46,5 @@ data class ContentAttachmentData(
VIDEO VIDEO
} }
fun getSafeMimeType() = if (mimeType == "image/jpg") "image/jpeg" else mimeType fun getSafeMimeType() = mimeType?.normalizeMimeType()
} }

View file

@ -18,8 +18,12 @@ package org.matrix.android.sdk.api.session.file
import android.net.Uri import android.net.Uri
import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.model.message.getFileName
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
import java.io.File import java.io.File
/** /**
@ -27,23 +31,6 @@ import java.io.File
*/ */
interface FileService { interface FileService {
enum class DownloadMode {
/**
* Download file in external storage
*/
TO_EXPORT,
/**
* Download file in cache
*/
FOR_INTERNAL_USE,
/**
* Download file in file provider path
*/
FOR_EXTERNAL_SHARE
}
enum class FileState { enum class FileState {
IN_CACHE, IN_CACHE,
DOWNLOADING, DOWNLOADING,
@ -54,34 +41,79 @@ interface FileService {
* Download a file. * Download a file.
* Result will be a decrypted file, stored in the cache folder. url parameter will be used to create unique filename to avoid name collision. * Result will be a decrypted file, stored in the cache folder. url parameter will be used to create unique filename to avoid name collision.
*/ */
fun downloadFile( fun downloadFile(fileName: String,
downloadMode: DownloadMode, mimeType: String?,
id: String, url: String?,
fileName: String, elementToDecrypt: ElementToDecrypt?,
mimeType: String?, callback: MatrixCallback<File>): Cancelable
url: String?,
elementToDecrypt: ElementToDecrypt?,
callback: MatrixCallback<File>): Cancelable
fun isFileInCache(mxcUrl: String, mimeType: String?): Boolean fun downloadFile(messageContent: MessageWithAttachmentContent,
callback: MatrixCallback<File>): Cancelable =
downloadFile(
fileName = messageContent.getFileName(),
mimeType = messageContent.mimeType,
url = messageContent.getFileUrl(),
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
callback = callback
)
fun isFileInCache(mxcUrl: String?,
fileName: String,
mimeType: String?,
elementToDecrypt: ElementToDecrypt?
): Boolean
fun isFileInCache(messageContent: MessageWithAttachmentContent) =
isFileInCache(
mxcUrl = messageContent.getFileUrl(),
fileName = messageContent.getFileName(),
mimeType = messageContent.mimeType,
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt())
/** /**
* Use this URI and pass it to intent using flag Intent.FLAG_GRANT_READ_URI_PERMISSION * Use this URI and pass it to intent using flag Intent.FLAG_GRANT_READ_URI_PERMISSION
* (if not other app won't be able to access it) * (if not other app won't be able to access it)
*/ */
fun getTemporarySharableURI(mxcUrl: String, mimeType: String?): Uri? fun getTemporarySharableURI(mxcUrl: String?,
fileName: String,
mimeType: String?,
elementToDecrypt: ElementToDecrypt?): Uri?
fun getTemporarySharableURI(messageContent: MessageWithAttachmentContent): Uri? =
getTemporarySharableURI(
mxcUrl = messageContent.getFileUrl(),
fileName = messageContent.getFileName(),
mimeType = messageContent.mimeType,
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt()
)
/** /**
* Get information on the given file. * Get information on the given file.
* Mimetype should be the same one as passed to downloadFile (limitation for now) * Mimetype should be the same one as passed to downloadFile (limitation for now)
*/ */
fun fileState(mxcUrl: String, mimeType: String?): FileState fun fileState(mxcUrl: String?,
fileName: String,
mimeType: String?,
elementToDecrypt: ElementToDecrypt?): FileState
fun fileState(messageContent: MessageWithAttachmentContent): FileState =
fileState(
mxcUrl = messageContent.getFileUrl(),
fileName = messageContent.getFileName(),
mimeType = messageContent.mimeType,
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt()
)
/** /**
* Clears all the files downloaded by the service * Clears all the files downloaded by the service, including decrypted files
*/ */
fun clearCache() fun clearCache()
/**
* Clears all the decrypted files by the service
*/
fun clearDecryptedCache()
/** /**
* Get size of cached files * Get size of cached files
*/ */

View file

@ -0,0 +1,50 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.session.media
import org.matrix.android.sdk.api.cache.CacheStrategy
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.util.JsonDict
interface MediaService {
/**
* Extract URLs from an Event.
* @return the list of URLs contains in the body of the Event. It does not mean that URLs in this list have UrlPreview data
*/
fun extractUrls(event: Event): List<String>
/**
* Get Raw Url Preview data from the homeserver. There is no cache management for this request
* @param url The url to get the preview data from
* @param timestamp The optional timestamp
*/
suspend fun getRawPreviewUrl(url: String, timestamp: Long?): JsonDict
/**
* Get Url Preview data from the homeserver, or from cache, depending on the cache strategy
* @param url The url to get the preview data from
* @param timestamp The optional timestamp. Note that this parameter is not taken into account
* if the data is already in cache and the cache strategy allow to use it
* @param cacheStrategy the cache strategy, see the type for more details
*/
suspend fun getPreviewUrl(url: String, timestamp: Long?, cacheStrategy: CacheStrategy): PreviewUrlData
/**
* Clear the cache of all retrieved UrlPreview data
*/
suspend fun clearCache()
}

View file

@ -0,0 +1,51 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.session.media
/**
* Facility data class to get the common field of a PreviewUrl response form the server
*
* Example of return data for the url `https://matrix.org`:
* <pre>
* {
* "matrix:image:size": 112805,
* "og:description": "Matrix is an open standard for interoperable, decentralised, real-time communication",
* "og:image": "mxc://matrix.org/2020-12-03_uFqjagCCTJbaaJxb",
* "og:image:alt": "Matrix is an open standard for interoperable, decentralised, real-time communication",
* "og:image:height": 467,
* "og:image:type": "image/jpeg",
* "og:image:width": 911,
* "og:locale": "en_US",
* "og:site_name": "Matrix.org",
* "og:title": "Matrix.org",
* "og:type": "website",
* "og:url": "https://matrix.org"
* }
* </pre>
*/
data class PreviewUrlData(
// Value of field "og:url". If not provided, this is the value passed in parameter
val url: String,
// Value of field "og:site_name"
val siteName: String?,
// Value of field "og:title"
val title: String?,
// Value of field "og:description"
val description: String?,
// Value of field "og:image"
val mxcUrl: String?
)

View file

@ -20,6 +20,7 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Content
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileInfo import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileInfo
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@ -54,5 +55,5 @@ data class MessageImageContent(
@Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null @Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null
) : MessageImageInfoContent { ) : MessageImageInfoContent {
override val mimeType: String? override val mimeType: String?
get() = encryptedFileInfo?.mimetype ?: info?.mimeType ?: "image/*" get() = encryptedFileInfo?.mimetype ?: info?.mimeType ?: MimeTypes.Images
} }

View file

@ -0,0 +1,38 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.util
import org.matrix.android.sdk.api.extensions.orFalse
// The Android SDK does not provide constant for mime type, add some of them here
object MimeTypes {
const val Any: String = "*/*"
const val OctetStream = "application/octet-stream"
const val Images = "image/*"
const val Png = "image/png"
const val BadJpg = "image/jpg"
const val Jpeg = "image/jpeg"
const val Gif = "image/gif"
fun String?.normalizeMimeType() = if (this == BadJpg) Jpeg else this
fun String?.isMimeTypeImage() = this?.startsWith("image/").orFalse()
fun String?.isMimeTypeVideo() = this?.startsWith("video/").orFalse()
fun String?.isMimeTypeAudio() = this?.startsWith("audio/").orFalse()
}

View file

@ -20,6 +20,7 @@ import io.realm.DynamicRealm
import io.realm.RealmMigration import io.realm.RealmMigration
import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields
import org.matrix.android.sdk.internal.database.model.PendingThreePidEntityFields import org.matrix.android.sdk.internal.database.model.PendingThreePidEntityFields
import org.matrix.android.sdk.internal.database.model.PreviewUrlCacheEntityFields
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -27,7 +28,7 @@ import javax.inject.Inject
class RealmSessionStoreMigration @Inject constructor() : RealmMigration { class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
companion object { companion object {
const val SESSION_STORE_SCHEMA_VERSION = 5L const val SESSION_STORE_SCHEMA_VERSION = 6L
} }
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
@ -38,6 +39,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
if (oldVersion <= 2) migrateTo3(realm) if (oldVersion <= 2) migrateTo3(realm)
if (oldVersion <= 3) migrateTo4(realm) if (oldVersion <= 3) migrateTo4(realm)
if (oldVersion <= 4) migrateTo5(realm) if (oldVersion <= 4) migrateTo5(realm)
if (oldVersion <= 5) migrateTo6(realm)
} }
private fun migrateTo1(realm: DynamicRealm) { private fun migrateTo1(realm: DynamicRealm) {
@ -89,4 +91,18 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
?.removeField("adminE2EByDefault") ?.removeField("adminE2EByDefault")
?.removeField("preferredJitsiDomain") ?.removeField("preferredJitsiDomain")
} }
private fun migrateTo6(realm: DynamicRealm) {
Timber.d("Step 5 -> 6")
realm.schema.create("PreviewUrlCacheEntity")
.addField(PreviewUrlCacheEntityFields.URL, String::class.java)
.setRequired(PreviewUrlCacheEntityFields.URL, true)
.addPrimaryKey(PreviewUrlCacheEntityFields.URL)
.addField(PreviewUrlCacheEntityFields.URL_FROM_SERVER, String::class.java)
.addField(PreviewUrlCacheEntityFields.SITE_NAME, String::class.java)
.addField(PreviewUrlCacheEntityFields.TITLE, String::class.java)
.addField(PreviewUrlCacheEntityFields.DESCRIPTION, String::class.java)
.addField(PreviewUrlCacheEntityFields.MXC_URL, String::class.java)
.addField(PreviewUrlCacheEntityFields.LAST_UPDATED_TIMESTAMP, Long::class.java)
}
} }

View file

@ -0,0 +1,36 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.database.model
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
internal open class PreviewUrlCacheEntity(
@PrimaryKey
var url: String = "",
var urlFromServer: String? = null,
var siteName: String? = null,
var title: String? = null,
var description: String? = null,
var mxcUrl: String? = null,
var lastUpdatedTimestamp: Long = 0L
) : RealmObject() {
companion object
}

View file

@ -48,6 +48,7 @@ import io.realm.annotations.RealmModule
PushRulesEntity::class, PushRulesEntity::class,
PushRuleEntity::class, PushRuleEntity::class,
PushConditionEntity::class, PushConditionEntity::class,
PreviewUrlCacheEntity::class,
PusherEntity::class, PusherEntity::class,
PusherDataEntity::class, PusherDataEntity::class,
ReadReceiptsSummaryEntity::class, ReadReceiptsSummaryEntity::class,

View file

@ -0,0 +1,39 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.database.query
import io.realm.Realm
import io.realm.kotlin.createObject
import io.realm.kotlin.where
import org.matrix.android.sdk.internal.database.model.PreviewUrlCacheEntity
import org.matrix.android.sdk.internal.database.model.PreviewUrlCacheEntityFields
/**
* Get the current PreviewUrlCacheEntity, return null if it does not exist
*/
internal fun PreviewUrlCacheEntity.Companion.get(realm: Realm, url: String): PreviewUrlCacheEntity? {
return realm.where<PreviewUrlCacheEntity>()
.equalTo(PreviewUrlCacheEntityFields.URL, url)
.findFirst()
}
/**
* Get the current PreviewUrlCacheEntity, create one if it does not exist
*/
internal fun PreviewUrlCacheEntity.Companion.getOrCreate(realm: Realm, url: String): PreviewUrlCacheEntity {
return get(realm, url) ?: realm.createObject(url)
}

View file

@ -71,9 +71,6 @@ internal interface MatrixComponent {
@CacheDirectory @CacheDirectory
fun cacheDir(): File fun cacheDir(): File
@ExternalFilesDirectory
fun externalFilesDir(): File?
fun olmManager(): OlmManager fun olmManager(): OlmManager
fun taskExecutor(): TaskExecutor fun taskExecutor(): TaskExecutor

View file

@ -57,13 +57,6 @@ internal object MatrixModule {
return context.cacheDir return context.cacheDir
} }
@JvmStatic
@Provides
@ExternalFilesDirectory
fun providesExternalFilesDir(context: Context): File? {
return context.getExternalFilesDir(null)
}
@JvmStatic @JvmStatic
@Provides @Provides
@MatrixScope @MatrixScope

View file

@ -16,14 +16,15 @@
package org.matrix.android.sdk.internal.network package org.matrix.android.sdk.internal.network
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.shouldBeRetried
import org.matrix.android.sdk.internal.network.ssl.CertUtil
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.shouldBeRetried
import org.matrix.android.sdk.internal.network.ssl.CertUtil
import retrofit2.Call import retrofit2.Call
import retrofit2.awaitResponse import retrofit2.awaitResponse
import timber.log.Timber
import java.io.IOException import java.io.IOException
internal suspend inline fun <DATA : Any> executeRequest(eventBus: EventBus?, internal suspend inline fun <DATA : Any> executeRequest(eventBus: EventBus?,
@ -49,6 +50,9 @@ internal class Request<DATA : Any>(private val eventBus: EventBus?) {
throw response.toFailure(eventBus) throw response.toFailure(eventBus)
} }
} catch (exception: Throwable) { } catch (exception: Throwable) {
// Log some details about the request which has failed
Timber.e("Exception when executing request ${apiCall.request().method} ${apiCall.request().url.toString().substringBefore("?")}")
// Check if this is a certificateException // Check if this is a certificateException
CertUtil.getCertificateException(exception) CertUtil.getCertificateException(exception)
// TODO Support certificate error once logged // TODO Support certificate error once logged

View file

@ -16,7 +16,7 @@
package org.matrix.android.sdk.internal.raw package org.matrix.android.sdk.internal.raw
import org.matrix.android.sdk.api.raw.RawCacheStrategy import org.matrix.android.sdk.api.cache.CacheStrategy
import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.raw.RawService
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@ -25,15 +25,15 @@ internal class DefaultRawService @Inject constructor(
private val getUrlTask: GetUrlTask, private val getUrlTask: GetUrlTask,
private val cleanRawCacheTask: CleanRawCacheTask private val cleanRawCacheTask: CleanRawCacheTask
) : RawService { ) : RawService {
override suspend fun getUrl(url: String, rawCacheStrategy: RawCacheStrategy): String { override suspend fun getUrl(url: String, cacheStrategy: CacheStrategy): String {
return getUrlTask.execute(GetUrlTask.Params(url, rawCacheStrategy)) return getUrlTask.execute(GetUrlTask.Params(url, cacheStrategy))
} }
override suspend fun getWellknown(userId: String): String { override suspend fun getWellknown(userId: String): String {
val homeServerDomain = userId.substringAfter(":") val homeServerDomain = userId.substringAfter(":")
return getUrl( return getUrl(
"https://$homeServerDomain/.well-known/matrix/client", "https://$homeServerDomain/.well-known/matrix/client",
RawCacheStrategy.TtlCache(TimeUnit.HOURS.toMillis(8), false) CacheStrategy.TtlCache(TimeUnit.HOURS.toMillis(8), false)
) )
} }

View file

@ -18,7 +18,7 @@ package org.matrix.android.sdk.internal.raw
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import okhttp3.ResponseBody import okhttp3.ResponseBody
import org.matrix.android.sdk.api.raw.RawCacheStrategy import org.matrix.android.sdk.api.cache.CacheStrategy
import org.matrix.android.sdk.internal.database.model.RawCacheEntity import org.matrix.android.sdk.internal.database.model.RawCacheEntity
import org.matrix.android.sdk.internal.database.query.get import org.matrix.android.sdk.internal.database.query.get
import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrCreate
@ -32,7 +32,7 @@ import javax.inject.Inject
internal interface GetUrlTask : Task<GetUrlTask.Params, String> { internal interface GetUrlTask : Task<GetUrlTask.Params, String> {
data class Params( data class Params(
val url: String, val url: String,
val rawCacheStrategy: RawCacheStrategy val cacheStrategy: CacheStrategy
) )
} }
@ -42,14 +42,14 @@ internal class DefaultGetUrlTask @Inject constructor(
) : GetUrlTask { ) : GetUrlTask {
override suspend fun execute(params: GetUrlTask.Params): String { override suspend fun execute(params: GetUrlTask.Params): String {
return when (params.rawCacheStrategy) { return when (params.cacheStrategy) {
RawCacheStrategy.NoCache -> doRequest(params.url) CacheStrategy.NoCache -> doRequest(params.url)
is RawCacheStrategy.TtlCache -> doRequestWithCache( is CacheStrategy.TtlCache -> doRequestWithCache(
params.url, params.url,
params.rawCacheStrategy.validityDurationInMillis, params.cacheStrategy.validityDurationInMillis,
params.rawCacheStrategy.strict params.cacheStrategy.strict
) )
RawCacheStrategy.InfiniteCache -> doRequestWithCache( CacheStrategy.InfiniteCache -> doRequestWithCache(
params.url, params.url,
Long.MAX_VALUE, Long.MAX_VALUE,
true true

View file

@ -21,6 +21,10 @@ import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import arrow.core.Try import arrow.core.Try
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.api.session.content.ContentUrlResolver
@ -29,35 +33,21 @@ import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.NoOpCancellable import org.matrix.android.sdk.api.util.NoOpCancellable
import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt
import org.matrix.android.sdk.internal.crypto.attachments.MXEncryptedAttachments import org.matrix.android.sdk.internal.crypto.attachments.MXEncryptedAttachments
import org.matrix.android.sdk.internal.di.CacheDirectory
import org.matrix.android.sdk.internal.di.ExternalFilesDirectory
import org.matrix.android.sdk.internal.di.SessionDownloadsDirectory import org.matrix.android.sdk.internal.di.SessionDownloadsDirectory
import org.matrix.android.sdk.internal.di.UnauthenticatedWithCertificateWithProgress import org.matrix.android.sdk.internal.di.UnauthenticatedWithCertificateWithProgress
import org.matrix.android.sdk.internal.session.download.DownloadProgressInterceptor.Companion.DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER import org.matrix.android.sdk.internal.session.download.DownloadProgressInterceptor.Companion.DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER
import org.matrix.android.sdk.internal.task.TaskExecutor import org.matrix.android.sdk.internal.task.TaskExecutor
import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers
import org.matrix.android.sdk.internal.util.md5
import org.matrix.android.sdk.internal.util.toCancelable import org.matrix.android.sdk.internal.util.toCancelable
import org.matrix.android.sdk.internal.util.writeToFile import org.matrix.android.sdk.internal.util.writeToFile
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.buffer
import okio.sink
import okio.source
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.net.URLEncoder
import javax.inject.Inject import javax.inject.Inject
internal class DefaultFileService @Inject constructor( internal class DefaultFileService @Inject constructor(
private val context: Context, private val context: Context,
@CacheDirectory
private val cacheDirectory: File,
@ExternalFilesDirectory
private val externalFilesDirectory: File?,
@SessionDownloadsDirectory @SessionDownloadsDirectory
private val sessionCacheDirectory: File, private val sessionCacheDirectory: File,
private val contentUrlResolver: ContentUrlResolver, private val contentUrlResolver: ContentUrlResolver,
@ -67,9 +57,17 @@ internal class DefaultFileService @Inject constructor(
private val taskExecutor: TaskExecutor private val taskExecutor: TaskExecutor
) : FileService { ) : FileService {
private fun String.safeFileName() = URLEncoder.encode(this, Charsets.US_ASCII.displayName()) // Legacy folder, will be deleted
private val legacyFolder = File(sessionCacheDirectory, "MF")
// Folder to store downloaded files (not decrypted)
private val downloadFolder = File(sessionCacheDirectory, "F")
// Folder to store decrypted files
private val decryptedFolder = File(downloadFolder, "D")
private val downloadFolder = File(sessionCacheDirectory, "MF") init {
// Clear the legacy downloaded files
legacyFolder.deleteRecursively()
}
/** /**
* Retain ongoing downloads to avoid re-downloading and already downloading file * Retain ongoing downloads to avoid re-downloading and already downloading file
@ -81,28 +79,26 @@ internal class DefaultFileService @Inject constructor(
* Download file in the cache folder, and eventually decrypt it * Download file in the cache folder, and eventually decrypt it
* TODO looks like files are copied 3 times * TODO looks like files are copied 3 times
*/ */
override fun downloadFile(downloadMode: FileService.DownloadMode, override fun downloadFile(fileName: String,
id: String,
fileName: String,
mimeType: String?, mimeType: String?,
url: String?, url: String?,
elementToDecrypt: ElementToDecrypt?, elementToDecrypt: ElementToDecrypt?,
callback: MatrixCallback<File>): Cancelable { callback: MatrixCallback<File>): Cancelable {
val unwrappedUrl = url ?: return NoOpCancellable.also { url ?: return NoOpCancellable.also {
callback.onFailure(IllegalArgumentException("url is null")) callback.onFailure(IllegalArgumentException("url is null"))
} }
Timber.v("## FileService downloadFile $unwrappedUrl") Timber.v("## FileService downloadFile $url")
synchronized(ongoing) { synchronized(ongoing) {
val existing = ongoing[unwrappedUrl] val existing = ongoing[url]
if (existing != null) { if (existing != null) {
Timber.v("## FileService downloadFile is already downloading.. ") Timber.v("## FileService downloadFile is already downloading.. ")
existing.add(callback) existing.add(callback)
return NoOpCancellable return NoOpCancellable
} else { } else {
// mark as tracked // mark as tracked
ongoing[unwrappedUrl] = ArrayList() ongoing[url] = ArrayList()
// and proceed to download // and proceed to download
} }
} }
@ -110,15 +106,15 @@ internal class DefaultFileService @Inject constructor(
return taskExecutor.executorScope.launch(coroutineDispatchers.main) { return taskExecutor.executorScope.launch(coroutineDispatchers.main) {
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
Try { Try {
if (!downloadFolder.exists()) { if (!decryptedFolder.exists()) {
downloadFolder.mkdirs() decryptedFolder.mkdirs()
} }
// ensure we use unique file name by using URL (mapped to suitable file name) // ensure we use unique file name by using URL (mapped to suitable file name)
// Also we need to add extension for the FileProvider, if not it lot's of app that it's // Also we need to add extension for the FileProvider, if not it lot's of app that it's
// shared with will not function well (even if mime type is passed in the intent) // shared with will not function well (even if mime type is passed in the intent)
File(downloadFolder, fileForUrl(unwrappedUrl, mimeType)) getFiles(url, fileName, mimeType, elementToDecrypt != null)
}.flatMap { destFile -> }.flatMap { cachedFiles ->
if (!destFile.exists()) { if (!cachedFiles.file.exists()) {
val resolvedUrl = contentUrlResolver.resolveFullSize(url) ?: return@flatMap Try.Failure(IllegalArgumentException("url is null")) val resolvedUrl = contentUrlResolver.resolveFullSize(url) ?: return@flatMap Try.Failure(IllegalArgumentException("url is null"))
val request = Request.Builder() val request = Request.Builder()
@ -141,79 +137,153 @@ internal class DefaultFileService @Inject constructor(
Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${!source.exhausted()}") Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${!source.exhausted()}")
if (elementToDecrypt != null) { // Write the file to cache (encrypted version if the file is encrypted)
Timber.v("## FileService: decrypt file") writeToFile(source.inputStream(), cachedFiles.file)
val decryptSuccess = destFile.outputStream().buffered().use { response.close()
MXEncryptedAttachments.decryptAttachment(
source.inputStream(),
elementToDecrypt,
it
)
}
response.close()
if (!decryptSuccess) {
return@flatMap Try.Failure(IllegalStateException("Decryption error"))
}
} else {
writeToFile(source.inputStream(), destFile)
response.close()
}
} else { } else {
Timber.v("## FileService: cache hit for $url") Timber.v("## FileService: cache hit for $url")
} }
Try.just(copyFile(destFile, downloadMode)) Try.just(cachedFiles)
} }
}.fold({ }.flatMap { cachedFiles ->
callback.onFailure(it) // Decrypt if necessary
// notify concurrent requests if (cachedFiles.decryptedFile != null) {
val toNotify = synchronized(ongoing) { if (!cachedFiles.decryptedFile.exists()) {
ongoing[unwrappedUrl]?.also { Timber.v("## FileService: decrypt file")
ongoing.remove(unwrappedUrl) // Ensure the parent folder exists
cachedFiles.decryptedFile.parentFile?.mkdirs()
val decryptSuccess = cachedFiles.file.inputStream().use { inputStream ->
cachedFiles.decryptedFile.outputStream().buffered().use { outputStream ->
MXEncryptedAttachments.decryptAttachment(
inputStream,
elementToDecrypt,
outputStream
)
}
}
if (!decryptSuccess) {
return@flatMap Try.Failure(IllegalStateException("Decryption error"))
}
} else {
Timber.v("## FileService: cache hit for decrypted file")
} }
Try.just(cachedFiles.decryptedFile)
} else {
// Clear file
Try.just(cachedFiles.file)
} }
toNotify?.forEach { otherCallbacks -> }.fold(
tryOrNull { otherCallbacks.onFailure(it) } { throwable ->
} callback.onFailure(throwable)
}, { file -> // notify concurrent requests
callback.onSuccess(file) val toNotify = synchronized(ongoing) {
// notify concurrent requests ongoing[url]?.also {
val toNotify = synchronized(ongoing) { ongoing.remove(url)
ongoing[unwrappedUrl]?.also { }
ongoing.remove(unwrappedUrl) }
toNotify?.forEach { otherCallbacks ->
tryOrNull { otherCallbacks.onFailure(throwable) }
}
},
{ file ->
callback.onSuccess(file)
// notify concurrent requests
val toNotify = synchronized(ongoing) {
ongoing[url]?.also {
ongoing.remove(url)
}
}
Timber.v("## FileService additional to notify ${toNotify?.size ?: 0} ")
toNotify?.forEach { otherCallbacks ->
tryOrNull { otherCallbacks.onSuccess(file) }
}
} }
} )
Timber.v("## FileService additional to notify ${toNotify?.size ?: 0} ")
toNotify?.forEach { otherCallbacks ->
tryOrNull { otherCallbacks.onSuccess(file) }
}
})
}.toCancelable() }.toCancelable()
} }
fun storeDataFor(url: String, mimeType: String?, inputStream: InputStream) { fun storeDataFor(mxcUrl: String,
val file = File(downloadFolder, fileForUrl(url, mimeType)) filename: String?,
val source = inputStream.source().buffer() mimeType: String?,
file.sink().buffer().let { sink -> originalFile: File,
source.use { input -> encryptedFile: File?) {
sink.use { output -> val files = getFiles(mxcUrl, filename, mimeType, encryptedFile != null)
output.writeAll(input) if (encryptedFile != null) {
// We switch the two files here, original file it the decrypted file
files.decryptedFile?.let { originalFile.copyTo(it) }
encryptedFile.copyTo(files.file)
} else {
// Just copy the original file
originalFile.copyTo(files.file)
}
}
private fun safeFileName(fileName: String?, mimeType: String?): String {
return buildString {
// filename has to be safe for the Android System
val result = fileName
?.replace("[^a-z A-Z0-9\\\\.\\-]".toRegex(), "_")
?.takeIf { it.isNotEmpty() }
?: DEFAULT_FILENAME
append(result)
// Check that the extension is correct regarding the mimeType
val extensionFromMime = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) }
if (extensionFromMime != null) {
// Compare
val fileExtension = result.substringAfterLast(delimiter = ".", missingDelimiterValue = "")
if (fileExtension.isEmpty() || fileExtension != extensionFromMime) {
// Missing extension, or diff in extension, add the one provided by the mimetype
append(".")
append(extensionFromMime)
} }
} }
} }
} }
private fun fileForUrl(url: String, mimeType: String?): String { override fun isFileInCache(mxcUrl: String?,
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) } fileName: String,
return if (extension != null) "${url.safeFileName()}.$extension" else url.safeFileName() mimeType: String?,
elementToDecrypt: ElementToDecrypt?): Boolean {
return fileState(mxcUrl, fileName, mimeType, elementToDecrypt) == FileService.FileState.IN_CACHE
} }
override fun isFileInCache(mxcUrl: String, mimeType: String?): Boolean { internal data class CachedFiles(
return File(downloadFolder, fileForUrl(mxcUrl, mimeType)).exists() // This is the downloaded file. Can be clear or encrypted
val file: File,
// This is the decrypted file. Null if the original file is not encrypted
val decryptedFile: File?
) {
fun getClearFile(): File = decryptedFile ?: file
} }
override fun fileState(mxcUrl: String, mimeType: String?): FileService.FileState { private fun getFiles(mxcUrl: String,
if (isFileInCache(mxcUrl, mimeType)) return FileService.FileState.IN_CACHE fileName: String?,
mimeType: String?,
isEncrypted: Boolean): CachedFiles {
val hashFolder = mxcUrl.md5()
val safeFileName = safeFileName(fileName, mimeType)
return if (isEncrypted) {
// Encrypted file
CachedFiles(
File(downloadFolder, "$hashFolder/$ENCRYPTED_FILENAME"),
File(decryptedFolder, "$hashFolder/$safeFileName")
)
} else {
// Clear file
CachedFiles(
File(downloadFolder, "$hashFolder/$safeFileName"),
null
)
}
}
override fun fileState(mxcUrl: String?,
fileName: String,
mimeType: String?,
elementToDecrypt: ElementToDecrypt?): FileService.FileState {
mxcUrl ?: return FileService.FileState.UNKNOWN
if (getFiles(mxcUrl, fileName, mimeType, elementToDecrypt != null).file.exists()) return FileService.FileState.IN_CACHE
val isDownloading = synchronized(ongoing) { val isDownloading = synchronized(ongoing) {
ongoing[mxcUrl] != null ongoing[mxcUrl] != null
} }
@ -224,26 +294,18 @@ internal class DefaultFileService @Inject constructor(
* Use this URI and pass it to intent using flag Intent.FLAG_GRANT_READ_URI_PERMISSION * Use this URI and pass it to intent using flag Intent.FLAG_GRANT_READ_URI_PERMISSION
* (if not other app won't be able to access it) * (if not other app won't be able to access it)
*/ */
override fun getTemporarySharableURI(mxcUrl: String, mimeType: String?): Uri? { override fun getTemporarySharableURI(mxcUrl: String?,
fileName: String,
mimeType: String?,
elementToDecrypt: ElementToDecrypt?): Uri? {
mxcUrl ?: return null
// this string could be extracted no? // this string could be extracted no?
val authority = "${context.packageName}.mx-sdk.fileprovider" val authority = "${context.packageName}.mx-sdk.fileprovider"
val targetFile = File(downloadFolder, fileForUrl(mxcUrl, mimeType)) val targetFile = getFiles(mxcUrl, fileName, mimeType, elementToDecrypt != null).getClearFile()
if (!targetFile.exists()) return null if (!targetFile.exists()) return null
return FileProvider.getUriForFile(context, authority, targetFile) return FileProvider.getUriForFile(context, authority, targetFile)
} }
private fun copyFile(file: File, downloadMode: FileService.DownloadMode): File {
// TODO some of this seems outdated, will need to be re-worked
return when (downloadMode) {
FileService.DownloadMode.TO_EXPORT ->
file.copyTo(File(externalFilesDirectory, file.name), true)
FileService.DownloadMode.FOR_EXTERNAL_SHARE ->
file.copyTo(File(File(cacheDirectory, "ext_share"), file.name), true)
FileService.DownloadMode.FOR_INTERNAL_USE ->
file
}
}
override fun getCacheSize(): Int { override fun getCacheSize(): Int {
return downloadFolder.walkTopDown() return downloadFolder.walkTopDown()
.onEnter { .onEnter {
@ -256,4 +318,14 @@ internal class DefaultFileService @Inject constructor(
override fun clearCache() { override fun clearCache() {
downloadFolder.deleteRecursively() downloadFolder.deleteRecursively()
} }
override fun clearDecryptedCache() {
decryptedFolder.deleteRecursively()
}
companion object {
private const val ENCRYPTED_FILENAME = "encrypted.bin"
// The extension would be added from the mimetype
private const val DEFAULT_FILENAME = "file"
}
} }

View file

@ -43,6 +43,7 @@ import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.api.session.group.GroupService import org.matrix.android.sdk.api.session.group.GroupService
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
import org.matrix.android.sdk.api.session.integrationmanager.IntegrationManagerService import org.matrix.android.sdk.api.session.integrationmanager.IntegrationManagerService
import org.matrix.android.sdk.api.session.media.MediaService
import org.matrix.android.sdk.api.session.permalinks.PermalinkService import org.matrix.android.sdk.api.session.permalinks.PermalinkService
import org.matrix.android.sdk.api.session.profile.ProfileService import org.matrix.android.sdk.api.session.profile.ProfileService
import org.matrix.android.sdk.api.session.pushers.PushersService import org.matrix.android.sdk.api.session.pushers.PushersService
@ -102,6 +103,7 @@ internal class DefaultSession @Inject constructor(
private val permalinkService: Lazy<PermalinkService>, private val permalinkService: Lazy<PermalinkService>,
private val secureStorageService: Lazy<SecureStorageService>, private val secureStorageService: Lazy<SecureStorageService>,
private val profileService: Lazy<ProfileService>, private val profileService: Lazy<ProfileService>,
private val mediaService: Lazy<MediaService>,
private val widgetService: Lazy<WidgetService>, private val widgetService: Lazy<WidgetService>,
private val syncThreadProvider: Provider<SyncThread>, private val syncThreadProvider: Provider<SyncThread>,
private val contentUrlResolver: ContentUrlResolver, private val contentUrlResolver: ContentUrlResolver,
@ -263,6 +265,8 @@ internal class DefaultSession @Inject constructor(
override fun widgetService(): WidgetService = widgetService.get() override fun widgetService(): WidgetService = widgetService.get()
override fun mediaService(): MediaService = mediaService.get()
override fun integrationManagerService() = integrationManagerService override fun integrationManagerService() = integrationManagerService
override fun callSignalingService(): CallSignalingService = callSignalingService.get() override fun callSignalingService(): CallSignalingService = callSignalingService.get()

View file

@ -40,6 +40,7 @@ import org.matrix.android.sdk.internal.session.group.GroupModule
import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesModule import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesModule
import org.matrix.android.sdk.internal.session.identity.IdentityModule import org.matrix.android.sdk.internal.session.identity.IdentityModule
import org.matrix.android.sdk.internal.session.integrationmanager.IntegrationManagerModule import org.matrix.android.sdk.internal.session.integrationmanager.IntegrationManagerModule
import org.matrix.android.sdk.internal.session.media.MediaModule
import org.matrix.android.sdk.internal.session.openid.OpenIdModule import org.matrix.android.sdk.internal.session.openid.OpenIdModule
import org.matrix.android.sdk.internal.session.profile.ProfileModule import org.matrix.android.sdk.internal.session.profile.ProfileModule
import org.matrix.android.sdk.internal.session.pushers.AddHttpPusherWorker import org.matrix.android.sdk.internal.session.pushers.AddHttpPusherWorker
@ -75,6 +76,7 @@ import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers
GroupModule::class, GroupModule::class,
ContentModule::class, ContentModule::class,
CacheModule::class, CacheModule::class,
MediaModule::class,
CryptoModule::class, CryptoModule::class,
PushersModule::class, PushersModule::class,
OpenIdModule::class, OpenIdModule::class,

View file

@ -50,6 +50,7 @@ import org.matrix.android.sdk.internal.database.EventInsertLiveObserver
import org.matrix.android.sdk.internal.database.RealmSessionProvider import org.matrix.android.sdk.internal.database.RealmSessionProvider
import org.matrix.android.sdk.internal.database.SessionRealmConfigurationFactory import org.matrix.android.sdk.internal.database.SessionRealmConfigurationFactory
import org.matrix.android.sdk.internal.di.Authenticated import org.matrix.android.sdk.internal.di.Authenticated
import org.matrix.android.sdk.internal.di.CacheDirectory
import org.matrix.android.sdk.internal.di.DeviceId import org.matrix.android.sdk.internal.di.DeviceId
import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.di.SessionDownloadsDirectory import org.matrix.android.sdk.internal.di.SessionDownloadsDirectory
@ -169,9 +170,9 @@ internal abstract class SessionModule {
@JvmStatic @JvmStatic
@Provides @Provides
@SessionDownloadsDirectory @SessionDownloadsDirectory
fun providesCacheDir(@SessionId sessionId: String, fun providesDownloadsCacheDir(@SessionId sessionId: String,
context: Context): File { @CacheDirectory cacheFile: File): File {
return File(context.cacheDir, "downloads/$sessionId") return File(cacheFile, "downloads/$sessionId")
} }
@JvmStatic @JvmStatic

View file

@ -20,6 +20,9 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ContentUploadResponse( internal data class ContentUploadResponse(
/**
* Required. The MXC URI to the uploaded content.
*/
@Json(name = "content_uri") val contentUri: String @Json(name = "content_uri") val contentUri: String
) )

View file

@ -20,6 +20,7 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.util.MimeTypes
import timber.log.Timber import timber.log.Timber
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@ -58,7 +59,7 @@ internal object ThumbnailExtractor {
height = thumbnailHeight, height = thumbnailHeight,
size = thumbnailSize.toLong(), size = thumbnailSize.toLong(),
bytes = outputStream.toByteArray(), bytes = outputStream.toByteArray(),
mimeType = "image/jpeg" mimeType = MimeTypes.Jpeg
) )
thumbnail.recycle() thumbnail.recycle()
outputStream.reset() outputStream.reset()

View file

@ -29,6 +29,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageFileContent import org.matrix.android.sdk.api.session.room.model.message.MessageFileContent
import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.internal.crypto.attachments.MXEncryptedAttachments import org.matrix.android.sdk.internal.crypto.attachments.MXEncryptedAttachments
import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileInfo import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileInfo
import org.matrix.android.sdk.internal.database.mapper.ContentMapper import org.matrix.android.sdk.internal.database.mapper.ContentMapper
@ -151,7 +152,10 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
params.attachment.size params.attachment.size
) )
if (attachment.type == ContentAttachmentData.Type.IMAGE && params.compressBeforeSending) { if (attachment.type == ContentAttachmentData.Type.IMAGE
// Do not compress gif
&& attachment.mimeType != MimeTypes.Gif
&& params.compressBeforeSending) {
fileToUpload = imageCompressor.compress(context, workingFile, MAX_IMAGE_SIZE, MAX_IMAGE_SIZE) fileToUpload = imageCompressor.compress(context, workingFile, MAX_IMAGE_SIZE, MAX_IMAGE_SIZE)
.also { compressedFile -> .also { compressedFile ->
// Get new Bitmap size // Get new Bitmap size
@ -174,14 +178,15 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
} }
} }
val encryptedFile: File?
val contentUploadResponse = if (params.isEncrypted) { val contentUploadResponse = if (params.isEncrypted) {
Timber.v("## FileService: Encrypt file") Timber.v("## FileService: Encrypt file")
val tmpEncrypted = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir) encryptedFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
.also { filesToDelete.add(it) } .also { filesToDelete.add(it) }
uploadedFileEncryptedFileInfo = uploadedFileEncryptedFileInfo =
MXEncryptedAttachments.encrypt(fileToUpload.inputStream(), attachment.getSafeMimeType(), tmpEncrypted) { read, total -> MXEncryptedAttachments.encrypt(fileToUpload.inputStream(), attachment.getSafeMimeType(), encryptedFile) { read, total ->
notifyTracker(params) { notifyTracker(params) {
contentUploadStateTracker.setEncrypting(it, read.toLong(), total.toLong()) contentUploadStateTracker.setEncrypting(it, read.toLong(), total.toLong())
} }
@ -190,18 +195,23 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
Timber.v("## FileService: Uploading file") Timber.v("## FileService: Uploading file")
fileUploader fileUploader
.uploadFile(tmpEncrypted, attachment.name, "application/octet-stream", progressListener) .uploadFile(encryptedFile, attachment.name, MimeTypes.OctetStream, progressListener)
} else { } else {
Timber.v("## FileService: Clear file") Timber.v("## FileService: Clear file")
encryptedFile = null
fileUploader fileUploader
.uploadFile(fileToUpload, attachment.name, attachment.getSafeMimeType(), progressListener) .uploadFile(fileToUpload, attachment.name, attachment.getSafeMimeType(), progressListener)
} }
Timber.v("## FileService: Update cache storage for ${contentUploadResponse.contentUri}") Timber.v("## FileService: Update cache storage for ${contentUploadResponse.contentUri}")
try { try {
context.contentResolver.openInputStream(attachment.queryUri)?.let { fileService.storeDataFor(
fileService.storeDataFor(contentUploadResponse.contentUri, params.attachment.getSafeMimeType(), it) mxcUrl = contentUploadResponse.contentUri,
} filename = params.attachment.name,
mimeType = params.attachment.getSafeMimeType(),
originalFile = workingFile,
encryptedFile = encryptedFile
)
Timber.v("## FileService: cache storage updated") Timber.v("## FileService: cache storage updated")
} catch (failure: Throwable) { } catch (failure: Throwable) {
Timber.e(failure, "## FileService: Failed to update file cache") Timber.e(failure, "## FileService: Failed to update file cache")
@ -252,7 +262,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
val encryptionResult = MXEncryptedAttachments.encryptAttachment(thumbnailData.bytes.inputStream(), thumbnailData.mimeType) val encryptionResult = MXEncryptedAttachments.encryptAttachment(thumbnailData.bytes.inputStream(), thumbnailData.mimeType)
val contentUploadResponse = fileUploader.uploadByteArray(encryptionResult.encryptedByteArray, val contentUploadResponse = fileUploader.uploadByteArray(encryptionResult.encryptedByteArray,
"thumb_${params.attachment.name}", "thumb_${params.attachment.name}",
"application/octet-stream", MimeTypes.OctetStream,
thumbnailProgressListener) thumbnailProgressListener)
UploadThumbnailResult( UploadThumbnailResult(
contentUploadResponse.contentUri, contentUploadResponse.contentUri,

View file

@ -22,19 +22,12 @@ import retrofit2.Call
import retrofit2.http.GET import retrofit2.http.GET
internal interface CapabilitiesAPI { internal interface CapabilitiesAPI {
/** /**
* Request the homeserver capabilities * Request the homeserver capabilities
*/ */
@GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "capabilities") @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "capabilities")
fun getCapabilities(): Call<GetCapabilitiesResult> fun getCapabilities(): Call<GetCapabilitiesResult>
/**
* Request the upload capabilities
*/
@GET(NetworkConstants.URI_API_MEDIA_PREFIX_PATH_R0 + "config")
fun getUploadCapabilities(): Call<GetUploadCapabilitiesResult>
/** /**
* Request the versions * Request the versions
*/ */

View file

@ -29,6 +29,8 @@ import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.session.integrationmanager.IntegrationManagerConfigExtractor import org.matrix.android.sdk.internal.session.integrationmanager.IntegrationManagerConfigExtractor
import org.matrix.android.sdk.internal.session.media.GetMediaConfigResult
import org.matrix.android.sdk.internal.session.media.MediaAPI
import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction import org.matrix.android.sdk.internal.util.awaitTransaction
import org.matrix.android.sdk.internal.wellknown.GetWellknownTask import org.matrix.android.sdk.internal.wellknown.GetWellknownTask
@ -40,6 +42,7 @@ internal interface GetHomeServerCapabilitiesTask : Task<Unit, Unit>
internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor( internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
private val capabilitiesAPI: CapabilitiesAPI, private val capabilitiesAPI: CapabilitiesAPI,
private val mediaAPI: MediaAPI,
@SessionDatabase private val monarchy: Monarchy, @SessionDatabase private val monarchy: Monarchy,
private val eventBus: EventBus, private val eventBus: EventBus,
private val getWellknownTask: GetWellknownTask, private val getWellknownTask: GetWellknownTask,
@ -67,9 +70,9 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
} }
}.getOrNull() }.getOrNull()
val uploadCapabilities = runCatching { val mediaConfig = runCatching {
executeRequest<GetUploadCapabilitiesResult>(eventBus) { executeRequest<GetMediaConfigResult>(eventBus) {
apiCall = capabilitiesAPI.getUploadCapabilities() apiCall = mediaAPI.getMediaConfig()
} }
}.getOrNull() }.getOrNull()
@ -83,11 +86,11 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
getWellknownTask.execute(GetWellknownTask.Params(userId, homeServerConnectionConfig)) getWellknownTask.execute(GetWellknownTask.Params(userId, homeServerConnectionConfig))
}.getOrNull() }.getOrNull()
insertInDb(capabilities, uploadCapabilities, versions, wellknownResult) insertInDb(capabilities, mediaConfig, versions, wellknownResult)
} }
private suspend fun insertInDb(getCapabilitiesResult: GetCapabilitiesResult?, private suspend fun insertInDb(getCapabilitiesResult: GetCapabilitiesResult?,
getUploadCapabilitiesResult: GetUploadCapabilitiesResult?, getMediaConfigResult: GetMediaConfigResult?,
getVersionResult: Versions?, getVersionResult: Versions?,
getWellknownResult: WellknownResult?) { getWellknownResult: WellknownResult?) {
monarchy.awaitTransaction { realm -> monarchy.awaitTransaction { realm ->
@ -97,8 +100,8 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
homeServerCapabilitiesEntity.canChangePassword = getCapabilitiesResult.canChangePassword() homeServerCapabilitiesEntity.canChangePassword = getCapabilitiesResult.canChangePassword()
} }
if (getUploadCapabilitiesResult != null) { if (getMediaConfigResult != null) {
homeServerCapabilitiesEntity.maxUploadFileSize = getUploadCapabilitiesResult.maxUploadSize homeServerCapabilitiesEntity.maxUploadFileSize = getMediaConfigResult.maxUploadSize
?: HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN ?: HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN
} }

View file

@ -0,0 +1,40 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.media
import com.zhuinden.monarchy.Monarchy
import io.realm.kotlin.where
import org.matrix.android.sdk.internal.database.model.PreviewUrlCacheEntity
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import javax.inject.Inject
internal interface ClearPreviewUrlCacheTask : Task<Unit, Unit>
internal class DefaultClearPreviewUrlCacheTask @Inject constructor(
@SessionDatabase private val monarchy: Monarchy
) : ClearPreviewUrlCacheTask {
override suspend fun execute(params: Unit) {
monarchy.awaitTransaction { realm ->
realm.where<PreviewUrlCacheEntity>()
.findAll()
.deleteAllFromRealm()
}
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.media
import androidx.collection.LruCache
import org.matrix.android.sdk.api.cache.CacheStrategy
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.media.MediaService
import org.matrix.android.sdk.api.session.media.PreviewUrlData
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.util.getOrPut
import javax.inject.Inject
internal class DefaultMediaService @Inject constructor(
private val clearPreviewUrlCacheTask: ClearPreviewUrlCacheTask,
private val getPreviewUrlTask: GetPreviewUrlTask,
private val getRawPreviewUrlTask: GetRawPreviewUrlTask,
private val urlsExtractor: UrlsExtractor
) : MediaService {
// Cache of extracted URLs
private val extractedUrlsCache = LruCache<String, List<String>>(1_000)
override fun extractUrls(event: Event): List<String> {
return extractedUrlsCache.getOrPut(event.cacheKey()) { urlsExtractor.extract(event) }
}
private fun Event.cacheKey() = "${eventId ?: ""}-${roomId ?: ""}"
override suspend fun getRawPreviewUrl(url: String, timestamp: Long?): JsonDict {
return getRawPreviewUrlTask.execute(GetRawPreviewUrlTask.Params(url, timestamp))
}
override suspend fun getPreviewUrl(url: String, timestamp: Long?, cacheStrategy: CacheStrategy): PreviewUrlData {
return getPreviewUrlTask.execute(GetPreviewUrlTask.Params(url, timestamp, cacheStrategy))
}
override suspend fun clearCache() {
extractedUrlsCache.evictAll()
clearPreviewUrlCacheTask.execute(Unit)
}
}

View file

@ -5,7 +5,7 @@
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * http://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
@ -14,13 +14,13 @@
* limitations under the License. * limitations under the License.
*/ */
package org.matrix.android.sdk.internal.session.homeserver package org.matrix.android.sdk.internal.session.media
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
internal data class GetUploadCapabilitiesResult( internal data class GetMediaConfigResult(
/** /**
* The maximum size an upload can be in bytes. Clients SHOULD use this as a guide when uploading content. * 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. * If not listed or null, the size limit should be treated as unknown.

View file

@ -0,0 +1,122 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.media
import com.zhuinden.monarchy.Monarchy
import org.greenrobot.eventbus.EventBus
import org.matrix.android.sdk.api.cache.CacheStrategy
import org.matrix.android.sdk.api.session.media.PreviewUrlData
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.database.model.PreviewUrlCacheEntity
import org.matrix.android.sdk.internal.database.query.get
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import java.util.Date
import javax.inject.Inject
internal interface GetPreviewUrlTask : Task<GetPreviewUrlTask.Params, PreviewUrlData> {
data class Params(
val url: String,
val timestamp: Long?,
val cacheStrategy: CacheStrategy
)
}
internal class DefaultGetPreviewUrlTask @Inject constructor(
private val mediaAPI: MediaAPI,
private val eventBus: EventBus,
@SessionDatabase private val monarchy: Monarchy
) : GetPreviewUrlTask {
override suspend fun execute(params: GetPreviewUrlTask.Params): PreviewUrlData {
return when (params.cacheStrategy) {
CacheStrategy.NoCache -> doRequest(params.url, params.timestamp)
is CacheStrategy.TtlCache -> doRequestWithCache(
params.url,
params.timestamp,
params.cacheStrategy.validityDurationInMillis,
params.cacheStrategy.strict
)
CacheStrategy.InfiniteCache -> doRequestWithCache(
params.url,
params.timestamp,
Long.MAX_VALUE,
true
)
}
}
private suspend fun doRequest(url: String, timestamp: Long?): PreviewUrlData {
return executeRequest<JsonDict>(eventBus) {
apiCall = mediaAPI.getPreviewUrlData(url, timestamp)
}
.toPreviewUrlData(url)
}
private fun JsonDict.toPreviewUrlData(url: String): PreviewUrlData {
return PreviewUrlData(
url = (get("og:url") as? String) ?: url,
siteName = get("og:site_name") as? String,
title = get("og:title") as? String,
description = get("og:description") as? String,
mxcUrl = get("og:image") as? String
)
}
private suspend fun doRequestWithCache(url: String, timestamp: Long?, validityDurationInMillis: Long, strict: Boolean): PreviewUrlData {
// Get data from cache
var dataFromCache: PreviewUrlData? = null
var isCacheValid = false
monarchy.doWithRealm { realm ->
val entity = PreviewUrlCacheEntity.get(realm, url)
dataFromCache = entity?.toDomain()
isCacheValid = entity != null && Date().time < entity.lastUpdatedTimestamp + validityDurationInMillis
}
val finalDataFromCache = dataFromCache
if (finalDataFromCache != null && isCacheValid) {
return finalDataFromCache
}
// No cache or outdated cache
val data = try {
doRequest(url, timestamp)
} catch (throwable: Throwable) {
// In case of error, we can return value from cache even if outdated
return finalDataFromCache
?.takeIf { !strict }
?: throw throwable
}
// Store cache
monarchy.awaitTransaction { realm ->
val previewUrlCacheEntity = PreviewUrlCacheEntity.getOrCreate(realm, url)
previewUrlCacheEntity.urlFromServer = data.url
previewUrlCacheEntity.siteName = data.siteName
previewUrlCacheEntity.title = data.title
previewUrlCacheEntity.description = data.description
previewUrlCacheEntity.mxcUrl = data.mxcUrl
previewUrlCacheEntity.lastUpdatedTimestamp = Date().time
}
return data
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.media
import org.greenrobot.eventbus.EventBus
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task
import javax.inject.Inject
internal interface GetRawPreviewUrlTask : Task<GetRawPreviewUrlTask.Params, JsonDict> {
data class Params(
val url: String,
val timestamp: Long?
)
}
internal class DefaultGetRawPreviewUrlTask @Inject constructor(
private val mediaAPI: MediaAPI,
private val eventBus: EventBus
) : GetRawPreviewUrlTask {
override suspend fun execute(params: GetRawPreviewUrlTask.Params): JsonDict {
return executeRequest(eventBus) {
apiCall = mediaAPI.getPreviewUrlData(params.url, params.timestamp)
}
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.media
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.network.NetworkConstants
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query
internal interface MediaAPI {
/**
* Retrieve the configuration of the content repository
* Ref: https://matrix.org/docs/spec/client_server/r0.6.1#get-matrix-media-r0-config
*/
@GET(NetworkConstants.URI_API_MEDIA_PREFIX_PATH_R0 + "config")
fun getMediaConfig(): Call<GetMediaConfigResult>
/**
* Get information about a URL for the client. Typically this is called when a client
* sees a URL in a message and wants to render a preview for the user.
* Ref: https://matrix.org/docs/spec/client_server/r0.6.1#get-matrix-media-r0-preview-url
* @param url Required. The URL to get a preview of.
* @param ts The preferred point in time to return a preview for. The server may return a newer version
* if it does not have the requested version available.
*/
@GET(NetworkConstants.URI_API_MEDIA_PREFIX_PATH_R0 + "preview_url")
fun getPreviewUrlData(@Query("url") url: String, @Query("ts") ts: Long?): Call<JsonDict>
}

View file

@ -0,0 +1,50 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.media
import dagger.Binds
import dagger.Module
import dagger.Provides
import org.matrix.android.sdk.api.session.media.MediaService
import org.matrix.android.sdk.internal.session.SessionScope
import retrofit2.Retrofit
@Module
internal abstract class MediaModule {
@Module
companion object {
@Provides
@JvmStatic
@SessionScope
fun providesMediaAPI(retrofit: Retrofit): MediaAPI {
return retrofit.create(MediaAPI::class.java)
}
}
@Binds
abstract fun bindMediaService(service: DefaultMediaService): MediaService
@Binds
abstract fun bindGetRawPreviewUrlTask(task: DefaultGetRawPreviewUrlTask): GetRawPreviewUrlTask
@Binds
abstract fun bindGetPreviewUrlTask(task: DefaultGetPreviewUrlTask): GetPreviewUrlTask
@Binds
abstract fun bindClearMediaCacheTask(task: DefaultClearPreviewUrlCacheTask): ClearPreviewUrlCacheTask
}

View file

@ -0,0 +1,31 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.media
import org.matrix.android.sdk.api.session.media.PreviewUrlData
import org.matrix.android.sdk.internal.database.model.PreviewUrlCacheEntity
/**
* PreviewUrlCacheEntity -> PreviewUrlData
*/
internal fun PreviewUrlCacheEntity.toDomain() = PreviewUrlData(
url = urlFromServer ?: url,
siteName = siteName,
title = title,
description = description,
mxcUrl = mxcUrl
)

View file

@ -0,0 +1,44 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.media
import android.util.Patterns
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import javax.inject.Inject
internal class UrlsExtractor @Inject constructor() {
// Sadly Patterns.WEB_URL_WITH_PROTOCOL is not public so filter the protocol later
private val urlRegex = Patterns.WEB_URL.toRegex()
fun extract(event: Event): List<String> {
return event.takeIf { it.getClearType() == EventType.MESSAGE }
?.getClearContent()
?.toModel<MessageContent>()
?.takeIf { it.msgType == MessageType.MSGTYPE_TEXT || it.msgType == MessageType.MSGTYPE_EMOTE }
?.body
?.let { urlRegex.findAll(it) }
?.map { it.value }
?.filter { it.startsWith("https://") || it.startsWith("http://") }
?.distinct()
?.toList()
.orEmpty()
}
}

View file

@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.api.session.profile.ProfileService import org.matrix.android.sdk.api.session.profile.ProfileService
import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.database.model.PendingThreePidEntity import org.matrix.android.sdk.internal.database.model.PendingThreePidEntity
import org.matrix.android.sdk.internal.database.model.UserThreePidEntity import org.matrix.android.sdk.internal.database.model.UserThreePidEntity
@ -80,7 +81,7 @@ internal class DefaultProfileService @Inject constructor(private val taskExecuto
override fun updateAvatar(userId: String, newAvatarUri: Uri, fileName: String, matrixCallback: MatrixCallback<Unit>): Cancelable { override fun updateAvatar(userId: String, newAvatarUri: Uri, fileName: String, matrixCallback: MatrixCallback<Unit>): Cancelable {
return taskExecutor.executorScope.launchToCallback(coroutineDispatchers.main, matrixCallback) { return taskExecutor.executorScope.launchToCallback(coroutineDispatchers.main, matrixCallback) {
val response = fileUploader.uploadFromUri(newAvatarUri, fileName, "image/jpeg") val response = fileUploader.uploadFromUri(newAvatarUri, fileName, MimeTypes.Jpeg)
setAvatarUrlTask.execute(SetAvatarUrlTask.Params(userId = userId, newAvatarUrl = response.contentUri)) setAvatarUrlTask.execute(SetAvatarUrlTask.Params(userId = userId, newAvatarUrl = response.contentUri))
userStore.updateAvatar(userId, response.contentUri) userStore.updateAvatar(userId, response.contentUri)
} }

View file

@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.identity.IdentityServiceError import org.matrix.android.sdk.api.session.identity.IdentityServiceError
import org.matrix.android.sdk.api.session.identity.toMedium import org.matrix.android.sdk.api.session.identity.toMedium
import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.internal.crypto.DeviceListManager import org.matrix.android.sdk.internal.crypto.DeviceListManager
import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM
import org.matrix.android.sdk.internal.di.AuthenticatedIdentity import org.matrix.android.sdk.internal.di.AuthenticatedIdentity
@ -96,7 +97,7 @@ internal class CreateRoomBodyBuilder @Inject constructor(
fileUploader.uploadFromUri( fileUploader.uploadFromUri(
uri = avatarUri, uri = avatarUri,
filename = UUID.randomUUID().toString(), filename = UUID.randomUUID().toString(),
mimeType = "image/jpeg") mimeType = MimeTypes.Jpeg)
} }
?.let { response -> ?.let { response ->
Event( Event(

View file

@ -177,7 +177,7 @@ internal class DefaultSendService @AssistedInject constructor(
val attachmentData = ContentAttachmentData( val attachmentData = ContentAttachmentData(
size = messageContent.info!!.size, size = messageContent.info!!.size,
mimeType = messageContent.info.mimeType!!, mimeType = messageContent.info.mimeType!!,
name = messageContent.body, name = messageContent.getFileName(),
queryUri = Uri.parse(messageContent.url), queryUri = Uri.parse(messageContent.url),
type = ContentAttachmentData.Type.FILE type = ContentAttachmentData.Type.FILE
) )
@ -210,6 +210,8 @@ internal class DefaultSendService @AssistedInject constructor(
override fun cancelSend(eventId: String) { override fun cancelSend(eventId: String) {
cancelSendTracker.markLocalEchoForCancel(eventId, roomId) cancelSendTracker.markLocalEchoForCancel(eventId, roomId)
// This is maybe the current task, so cancel it too
eventSenderProcessor.cancel(eventId, roomId)
taskExecutor.executorScope.launch { taskExecutor.executorScope.launch {
localEchoRepository.deleteFailedEcho(roomId, eventId) localEchoRepository.deleteFailedEcho(roomId, eventId)
} }

View file

@ -16,6 +16,7 @@
package org.matrix.android.sdk.internal.session.room.send.queue package org.matrix.android.sdk.internal.session.room.send.queue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.matrix.android.sdk.api.auth.data.SessionParams import org.matrix.android.sdk.api.auth.data.SessionParams
@ -106,17 +107,21 @@ internal class EventSenderProcessor @Inject constructor(
// non blocking add to queue // non blocking add to queue
sendingQueue.add(task) sendingQueue.add(task)
markAsManaged(task) markAsManaged(task)
return object : Cancelable { return task
override fun cancel() { }
task.cancel()
} fun cancel(eventId: String, roomId: String) {
} (currentTask as? SendEventQueuedTask)
?.takeIf { it -> it.event.eventId == eventId && it.event.roomId == roomId }
?.cancel()
} }
companion object { companion object {
private const val RETRY_WAIT_TIME_MS = 10_000L private const val RETRY_WAIT_TIME_MS = 10_000L
} }
private var currentTask: QueuedTask? = null
private var sendingQueue = LinkedBlockingQueue<QueuedTask>() private var sendingQueue = LinkedBlockingQueue<QueuedTask>()
private var networkAvailableLock = Object() private var networkAvailableLock = Object()
@ -129,6 +134,7 @@ internal class EventSenderProcessor @Inject constructor(
while (!isInterrupted) { while (!isInterrupted) {
Timber.v("## SendThread wait for task to process") Timber.v("## SendThread wait for task to process")
val task = sendingQueue.take() val task = sendingQueue.take()
.also { currentTask = it }
Timber.v("## SendThread Found task to process $task") Timber.v("## SendThread Found task to process $task")
if (task.isCancelled()) { if (task.isCancelled()) {
@ -183,6 +189,10 @@ internal class EventSenderProcessor @Inject constructor(
task.onTaskFailed() task.onTaskFailed()
throw InterruptedException() throw InterruptedException()
} }
exception is CancellationException -> {
Timber.v("## SendThread task has been cancelled")
break@retryLoop
}
else -> { else -> {
Timber.v("## SendThread retryLoop Un-Retryable error, try next task") Timber.v("## SendThread retryLoop Un-Retryable error, try next task")
// this task is in error, check next one? // this task is in error, check next one?

View file

@ -17,7 +17,6 @@
package org.matrix.android.sdk.internal.session.room.send.queue package org.matrix.android.sdk.internal.session.room.send.queue
import android.content.Context import android.content.Context
import org.matrix.android.sdk.api.auth.data.sessionId
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.crypto.CryptoService import org.matrix.android.sdk.api.session.crypto.CryptoService
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.send.SendState

View file

@ -16,14 +16,26 @@
package org.matrix.android.sdk.internal.session.room.send.queue package org.matrix.android.sdk.internal.session.room.send.queue
abstract class QueuedTask { import org.matrix.android.sdk.api.util.Cancelable
abstract class QueuedTask : Cancelable {
var retryCount = 0 var retryCount = 0
abstract suspend fun execute() private var hasBeenCancelled: Boolean = false
suspend fun execute() {
if (!isCancelled()) {
doExecute()
}
}
abstract suspend fun doExecute()
abstract fun onTaskFailed() abstract fun onTaskFailed()
abstract fun isCancelled() : Boolean open fun isCancelled() = hasBeenCancelled
abstract fun cancel() final override fun cancel() {
hasBeenCancelled = true
}
} }

View file

@ -22,20 +22,18 @@ import org.matrix.android.sdk.internal.session.room.send.CancelSendTracker
import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository
internal class RedactQueuedTask( internal class RedactQueuedTask(
val toRedactEventId: String, private val toRedactEventId: String,
val redactionLocalEchoId: String, val redactionLocalEchoId: String,
val roomId: String, private val roomId: String,
val reason: String?, private val reason: String?,
val redactEventTask: RedactEventTask, private val redactEventTask: RedactEventTask,
val localEchoRepository: LocalEchoRepository, private val localEchoRepository: LocalEchoRepository,
val cancelSendTracker: CancelSendTracker private val cancelSendTracker: CancelSendTracker
) : QueuedTask() { ) : QueuedTask() {
private var _isCancelled: Boolean = false override fun toString() = "[RedactQueuedTask $redactionLocalEchoId]"
override fun toString() = "[RedactEventRunnableTask $redactionLocalEchoId]" override suspend fun doExecute() {
override suspend fun execute() {
redactEventTask.execute(RedactEventTask.Params(redactionLocalEchoId, roomId, toRedactEventId, reason)) redactEventTask.execute(RedactEventTask.Params(redactionLocalEchoId, roomId, toRedactEventId, reason))
} }
@ -44,10 +42,6 @@ internal class RedactQueuedTask(
} }
override fun isCancelled(): Boolean { override fun isCancelled(): Boolean {
return _isCancelled || cancelSendTracker.isCancelRequestedFor(redactionLocalEchoId, roomId) return super.isCancelled() || cancelSendTracker.isCancelRequestedFor(redactionLocalEchoId, roomId)
}
override fun cancel() {
_isCancelled = true
} }
} }

View file

@ -33,11 +33,9 @@ internal class SendEventQueuedTask(
val cancelSendTracker: CancelSendTracker val cancelSendTracker: CancelSendTracker
) : QueuedTask() { ) : QueuedTask() {
private var _isCancelled: Boolean = false override fun toString() = "[SendEventQueuedTask ${event.eventId}]"
override fun toString() = "[SendEventRunnableTask ${event.eventId}]" override suspend fun doExecute() {
override suspend fun execute() {
sendEventTask.execute(SendEventTask.Params(event, encrypt)) sendEventTask.execute(SendEventTask.Params(event, encrypt))
} }
@ -56,10 +54,6 @@ internal class SendEventQueuedTask(
} }
override fun isCancelled(): Boolean { override fun isCancelled(): Boolean {
return _isCancelled || cancelSendTracker.isCancelRequestedFor(event.eventId, event.roomId) return super.isCancelled() || cancelSendTracker.isCancelRequestedFor(event.eventId, event.roomId)
}
override fun cancel() {
_isCancelled = true
} }
} }

View file

@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomJoinRules
import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent
import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.session.room.state.StateService
import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.session.content.FileUploader import org.matrix.android.sdk.internal.session.content.FileUploader
import org.matrix.android.sdk.internal.session.room.alias.AddRoomAliasTask import org.matrix.android.sdk.internal.session.room.alias.AddRoomAliasTask
@ -137,7 +138,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private
} }
override suspend fun updateAvatar(avatarUri: Uri, fileName: String) { override suspend fun updateAvatar(avatarUri: Uri, fileName: String) {
val response = fileUploader.uploadFromUri(avatarUri, fileName, "image/jpeg") val response = fileUploader.uploadFromUri(avatarUri, fileName, MimeTypes.Jpeg)
sendStateEvent( sendStateEvent(
eventType = EventType.STATE_ROOM_AVATAR, eventType = EventType.STATE_ROOM_AVATAR,
body = mapOf("url" to response.contentUri), body = mapOf("url" to response.contentUri),

View file

@ -25,7 +25,6 @@ import org.matrix.android.sdk.api.failure.isTokenError
import org.matrix.android.sdk.api.session.sync.SyncState import org.matrix.android.sdk.api.session.sync.SyncState
import org.matrix.android.sdk.internal.network.NetworkConnectivityChecker import org.matrix.android.sdk.internal.network.NetworkConnectivityChecker
import org.matrix.android.sdk.internal.session.sync.SyncTask import org.matrix.android.sdk.internal.session.sync.SyncTask
import org.matrix.android.sdk.internal.session.typing.DefaultTypingUsersTracker
import org.matrix.android.sdk.internal.util.BackgroundDetectionObserver import org.matrix.android.sdk.internal.util.BackgroundDetectionObserver
import org.matrix.android.sdk.internal.util.Debouncer import org.matrix.android.sdk.internal.util.Debouncer
import org.matrix.android.sdk.internal.util.createUIHandler import org.matrix.android.sdk.internal.util.createUIHandler
@ -50,14 +49,13 @@ private const val RETRY_WAIT_TIME_MS = 10_000L
private const val DEFAULT_LONG_POOL_TIMEOUT = 30_000L private const val DEFAULT_LONG_POOL_TIMEOUT = 30_000L
internal class SyncThread @Inject constructor(private val syncTask: SyncTask, internal class SyncThread @Inject constructor(private val syncTask: SyncTask,
private val typingUsersTracker: DefaultTypingUsersTracker,
private val networkConnectivityChecker: NetworkConnectivityChecker, private val networkConnectivityChecker: NetworkConnectivityChecker,
private val backgroundDetectionObserver: BackgroundDetectionObserver, private val backgroundDetectionObserver: BackgroundDetectionObserver,
private val activeCallHandler: ActiveCallHandler private val activeCallHandler: ActiveCallHandler
) : Thread("SyncThread"), NetworkConnectivityChecker.Listener, BackgroundDetectionObserver.Listener { ) : Thread("SyncThread"), NetworkConnectivityChecker.Listener, BackgroundDetectionObserver.Listener {
private var state: SyncState = SyncState.Idle private var state: SyncState = SyncState.Idle
private var liveState = MutableLiveData<SyncState>(state) private var liveState = MutableLiveData(state)
private val lock = Object() private val lock = Object()
private val syncScope = CoroutineScope(SupervisorJob()) private val syncScope = CoroutineScope(SupervisorJob())
private val debouncer = Debouncer(createUIHandler()) private val debouncer = Debouncer(createUIHandler())
@ -231,7 +229,7 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask,
return return
} }
state = newState state = newState
debouncer.debounce("post_state", Runnable { debouncer.debounce("post_state", {
liveState.value = newState liveState.value = newState
}, 150) }, 150)
} }

View file

@ -25,6 +25,9 @@ import java.io.InputStream
*/ */
@WorkerThread @WorkerThread
fun writeToFile(inputStream: InputStream, outputFile: File) { fun writeToFile(inputStream: InputStream, outputFile: File) {
// Ensure the parent folder exists, else it will crash
outputFile.parentFile?.mkdirs()
outputFile.outputStream().use { outputFile.outputStream().use {
inputStream.copyTo(it) inputStream.copyTo(it)
} }

View file

@ -0,0 +1,24 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.util
import androidx.collection.LruCache
@Suppress("NULLABLE_TYPE_PARAMETER_AGAINST_NOT_NULL_TYPE_PARAMETER")
internal inline fun <K, V> LruCache<K, V>.getOrPut(key: K, defaultValue: () -> V): V {
return get(key) ?: defaultValue().also { put(key, it) }
}

View file

@ -440,6 +440,10 @@ dependencies {
implementation 'com.google.zxing:core:3.3.3' implementation 'com.google.zxing:core:3.3.3'
implementation 'me.dm7.barcodescanner:zxing:1.9.13' implementation 'me.dm7.barcodescanner:zxing:1.9.13'
// Emoji Keyboard
implementation 'com.vanniktech:emoji-material:0.7.0'
implementation 'com.vanniktech:emoji-google:0.7.0'
// TESTS // TESTS
testImplementation 'junit:junit:4.13' testImplementation 'junit:junit:4.13'
testImplementation "org.amshove.kluent:kluent-android:$kluent_version" testImplementation "org.amshove.kluent:kluent-android:$kluent_version"

View file

@ -347,11 +347,6 @@ SOFTWARE.
<br/> <br/>
Copyright 2017 Gabriel Ittner. Copyright 2017 Gabriel Ittner.
</li> </li>
<li>
<b>Android-multipicker-library</b>
<br/>
Copyright 2018 Kumar Bibek
</li>
<li> <li>
<b>htmlcompressor</b> <b>htmlcompressor</b>
<br/> <br/>
@ -390,6 +385,11 @@ SOFTWARE.
<br/> <br/>
Copyright 2018, Aleksandr Nikiforov Copyright 2018, Aleksandr Nikiforov
</li> </li>
<li>
<b>Emoji</b>
<br/>
Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors
</li>
</ul> </ul>
<pre> <pre>
Apache License Apache License

View file

@ -36,6 +36,8 @@ import com.airbnb.epoxy.EpoxyAsyncUtil
import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyController
import com.facebook.stetho.Stetho import com.facebook.stetho.Stetho
import com.gabrielittner.threetenbp.LazyThreeTen import com.gabrielittner.threetenbp.LazyThreeTen
import com.vanniktech.emoji.EmojiManager
import com.vanniktech.emoji.google.GoogleEmojiProvider
import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.DaggerVectorComponent import im.vector.app.core.di.DaggerVectorComponent
import im.vector.app.core.di.HasVectorInjector import im.vector.app.core.di.HasVectorInjector
@ -184,6 +186,8 @@ class VectorApplication :
addAction(Intent.ACTION_SCREEN_OFF) addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_ON)
}) })
EmojiManager.install(GoogleEmojiProvider())
} }
private fun enableStrictModeIfNeeded() { private fun enableStrictModeIfNeeded() {

View file

@ -28,7 +28,6 @@ import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.ImageContentRenderer
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.file.FileService
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -110,11 +109,9 @@ class VectorGlideDataFetcher(private val activeSessionHolder: ActiveSessionHolde
} }
// Use the file vector service, will avoid flickering and redownload after upload // Use the file vector service, will avoid flickering and redownload after upload
fileService.downloadFile( fileService.downloadFile(
downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
mimeType = data.mimeType,
id = data.eventId,
url = data.url,
fileName = data.filename, fileName = data.filename,
mimeType = data.mimeType,
url = data.url,
elementToDecrypt = data.elementToDecrypt, elementToDecrypt = data.elementToDecrypt,
callback = object : MatrixCallback<File> { callback = object : MatrixCallback<File> {
override fun onSuccess(data: File) { override fun onSuccess(data: File) {

View file

@ -20,17 +20,11 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import im.vector.app.core.utils.getFileExtension import im.vector.app.core.utils.getFileExtension
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.api.util.MimeTypes.normalizeMimeType
import timber.log.Timber import timber.log.Timber
import java.io.InputStream import java.io.InputStream
/**
* Mime types
*/
const val MIME_TYPE_JPEG = "image/jpeg"
const val MIME_TYPE_JPG = "image/jpg"
const val MIME_TYPE_IMAGE_ALL = "image/*"
const val MIME_TYPE_ALL_CONTENT = "*/*"
data class Resource( data class Resource(
var mContentStream: InputStream? = null, var mContentStream: InputStream? = null,
var mMimeType: String? = null var mMimeType: String? = null
@ -55,7 +49,7 @@ data class Resource(
* @return true if the opened resource is a jpeg one. * @return true if the opened resource is a jpeg one.
*/ */
fun isJpegResource(): Boolean { fun isJpegResource(): Boolean {
return MIME_TYPE_JPEG == mMimeType || MIME_TYPE_JPG == mMimeType return mMimeType.normalizeMimeType() == MimeTypes.Jpeg
} }
} }

View file

@ -1,19 +1,17 @@
/* /*
* Copyright 2019 New Vector Ltd
* Copyright 2019 New Vector Ltd *
* * Licensed under the Apache License, Version 2.0 (the "License");
* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License.
* you may not use this file except in compliance with the License. * You may obtain a copy of the License at
* You may obtain a copy of the License at *
* * http://www.apache.org/licenses/LICENSE-2.0
* http://www.apache.org/licenses/LICENSE-2.0 *
* * Unless required by applicable law or agreed to in writing, software
* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS,
* 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. * See the License for the specific language governing permissions and
* See the License for the specific language governing permissions and * limitations under the License.
* limitations under the License.
*/ */
package im.vector.app.core.ui.views package im.vector.app.core.ui.views

View file

@ -1,19 +1,17 @@
/* /*
* Copyright 2019 New Vector Ltd
* Copyright 2019 New Vector Ltd *
* * Licensed under the Apache License, Version 2.0 (the "License");
* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License.
* you may not use this file except in compliance with the License. * You may obtain a copy of the License at
* You may obtain a copy of the License at *
* * http://www.apache.org/licenses/LICENSE-2.0
* http://www.apache.org/licenses/LICENSE-2.0 *
* * Unless required by applicable law or agreed to in writing, software
* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS,
* 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. * See the License for the specific language governing permissions and
* See the License for the specific language governing permissions and * limitations under the License.
* limitations under the License.
*/ */
package im.vector.app.core.utils package im.vector.app.core.utils

View file

@ -48,6 +48,10 @@ import okio.buffer
import okio.sink import okio.sink
import okio.source import okio.source
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.api.util.MimeTypes.isMimeTypeAudio
import org.matrix.android.sdk.api.util.MimeTypes.isMimeTypeImage
import org.matrix.android.sdk.api.util.MimeTypes.isMimeTypeVideo
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
@ -138,7 +142,7 @@ fun openFileSelection(activity: Activity,
fileIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultipleSelection) fileIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultipleSelection)
fileIntent.addCategory(Intent.CATEGORY_OPENABLE) fileIntent.addCategory(Intent.CATEGORY_OPENABLE)
fileIntent.type = "*/*" fileIntent.type = MimeTypes.Any
try { try {
activityResultLauncher activityResultLauncher
@ -182,7 +186,7 @@ fun openCamera(activity: Activity, titlePrefix: String, requestCode: Int): Strin
// The Galaxy S not only requires the name of the file to output the image to, but will also not // The Galaxy S not only requires the name of the file to output the image to, but will also not
// set the mime type of the picture it just took (!!!). We assume that the Galaxy S takes image/jpegs // set the mime type of the picture it just took (!!!). We assume that the Galaxy S takes image/jpegs
// so the attachment uploader doesn't freak out about there being no mimetype in the content database. // so the attachment uploader doesn't freak out about there being no mimetype in the content database.
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") values.put(MediaStore.Images.Media.MIME_TYPE, MimeTypes.Jpeg)
var dummyUri: Uri? = null var dummyUri: Uri? = null
try { try {
dummyUri = activity.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) dummyUri = activity.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
@ -344,10 +348,10 @@ fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
} }
val externalContentUri = when { val externalContentUri = when {
mediaMimeType?.startsWith("image/") == true -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI mediaMimeType?.isMimeTypeImage() == true -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
mediaMimeType?.startsWith("video/") == true -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI mediaMimeType?.isMimeTypeVideo() == true -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
mediaMimeType?.startsWith("audio/") == true -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI mediaMimeType?.isMimeTypeAudio() == true -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> MediaStore.Downloads.EXTERNAL_CONTENT_URI else -> MediaStore.Downloads.EXTERNAL_CONTENT_URI
} }
val uri = context.contentResolver.insert(externalContentUri, values) val uri = context.contentResolver.insert(externalContentUri, values)
@ -365,7 +369,7 @@ fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String
notificationUtils.buildDownloadFileNotification( notificationUtils.buildDownloadFileNotification(
uri, uri,
filename, filename,
mediaMimeType ?: "application/octet-stream" mediaMimeType ?: MimeTypes.OctetStream
).let { notification -> ).let { notification ->
notificationUtils.showNotificationMessage("DL", uri.hashCode(), notification) notificationUtils.showNotificationMessage("DL", uri.hashCode(), notification)
} }
@ -385,10 +389,10 @@ private fun saveMediaLegacy(context: Context, mediaMimeType: String?, title: Str
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
val dest = when { val dest = when {
mediaMimeType?.startsWith("image/") == true -> Environment.DIRECTORY_PICTURES mediaMimeType?.isMimeTypeImage() == true -> Environment.DIRECTORY_PICTURES
mediaMimeType?.startsWith("video/") == true -> Environment.DIRECTORY_MOVIES mediaMimeType?.isMimeTypeVideo() == true -> Environment.DIRECTORY_MOVIES
mediaMimeType?.startsWith("audio/") == true -> Environment.DIRECTORY_MUSIC mediaMimeType?.isMimeTypeAudio() == true -> Environment.DIRECTORY_MUSIC
else -> Environment.DIRECTORY_DOWNLOADS else -> Environment.DIRECTORY_DOWNLOADS
} }
val downloadDir = Environment.getExternalStoragePublicDirectory(dest) val downloadDir = Environment.getExternalStoragePublicDirectory(dest)
try { try {
@ -405,7 +409,7 @@ private fun saveMediaLegacy(context: Context, mediaMimeType: String?, title: Str
savedFile.name, savedFile.name,
title, title,
true, true,
mediaMimeType ?: "application/octet-stream", mediaMimeType ?: MimeTypes.OctetStream,
savedFile.absolutePath, savedFile.absolutePath,
savedFile.length(), savedFile.length(),
true) true)

View file

@ -1,19 +1,17 @@
/* /*
* Copyright 2019 New Vector Ltd
* Copyright 2019 New Vector Ltd *
* * Licensed under the Apache License, Version 2.0 (the "License");
* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License.
* you may not use this file except in compliance with the License. * You may obtain a copy of the License at
* You may obtain a copy of the License at *
* * http://www.apache.org/licenses/LICENSE-2.0
* http://www.apache.org/licenses/LICENSE-2.0 *
* * Unless required by applicable law or agreed to in writing, software
* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS,
* 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. * See the License for the specific language governing permissions and
* See the License for the specific language governing permissions and * limitations under the License.
* limitations under the License.
*/ */
package im.vector.app.core.utils package im.vector.app.core.utils

View file

@ -23,6 +23,9 @@ import im.vector.lib.multipicker.entity.MultiPickerFileType
import im.vector.lib.multipicker.entity.MultiPickerImageType import im.vector.lib.multipicker.entity.MultiPickerImageType
import im.vector.lib.multipicker.entity.MultiPickerVideoType import im.vector.lib.multipicker.entity.MultiPickerVideoType
import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.util.MimeTypes.isMimeTypeAudio
import org.matrix.android.sdk.api.util.MimeTypes.isMimeTypeImage
import org.matrix.android.sdk.api.util.MimeTypes.isMimeTypeVideo
import timber.log.Timber import timber.log.Timber
fun MultiPickerContactType.toContactAttachment(): ContactAttachment { fun MultiPickerContactType.toContactAttachment(): ContactAttachment {
@ -59,10 +62,10 @@ fun MultiPickerAudioType.toContentAttachmentData(): ContentAttachmentData {
private fun MultiPickerBaseType.mapType(): ContentAttachmentData.Type { private fun MultiPickerBaseType.mapType(): ContentAttachmentData.Type {
return when { return when {
mimeType?.startsWith("image/") == true -> ContentAttachmentData.Type.IMAGE mimeType?.isMimeTypeImage() == true -> ContentAttachmentData.Type.IMAGE
mimeType?.startsWith("video/") == true -> ContentAttachmentData.Type.VIDEO mimeType?.isMimeTypeVideo() == true -> ContentAttachmentData.Type.VIDEO
mimeType?.startsWith("audio/") == true -> ContentAttachmentData.Type.AUDIO mimeType?.isMimeTypeAudio() == true -> ContentAttachmentData.Type.AUDIO
else -> ContentAttachmentData.Type.FILE else -> ContentAttachmentData.Type.FILE
} }
} }

View file

@ -17,11 +17,19 @@
package im.vector.app.features.attachments package im.vector.app.features.attachments
import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.util.MimeTypes
private val listOfPreviewableMimeTypes = listOf(
MimeTypes.Jpeg,
MimeTypes.BadJpg,
MimeTypes.Png,
MimeTypes.Gif
)
fun ContentAttachmentData.isPreviewable(): Boolean { fun ContentAttachmentData.isPreviewable(): Boolean {
// For now the preview only supports still image // For now the preview only supports still image
return type == ContentAttachmentData.Type.IMAGE return type == ContentAttachmentData.Type.IMAGE
&& listOf("image/jpeg", "image/png", "image/jpg").contains(getSafeMimeType() ?: "") && listOfPreviewableMimeTypes.contains(getSafeMimeType() ?: "")
} }
data class GroupedContentAttachmentData( data class GroupedContentAttachmentData(

View file

@ -17,12 +17,14 @@
package im.vector.app.features.attachments.preview package im.vector.app.features.attachments.preview
import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.api.util.MimeTypes.isMimeTypeImage
/** /**
* All images are editable, expect Gif * All images are editable, expect Gif
*/ */
fun ContentAttachmentData.isEditable(): Boolean { fun ContentAttachmentData.isEditable(): Boolean {
return type == ContentAttachmentData.Type.IMAGE return type == ContentAttachmentData.Type.IMAGE
&& getSafeMimeType()?.startsWith("image/") == true && getSafeMimeType()?.isMimeTypeImage() == true
&& getSafeMimeType() != "image/gif" && getSafeMimeType() != MimeTypes.Gif
} }

View file

@ -71,12 +71,18 @@ class HomeActivityViewModel @AssistedInject constructor(
private var onceTrusted = false private var onceTrusted = false
init { init {
cleanupFiles()
observeInitialSync() observeInitialSync()
mayBeInitializeCrossSigning() mayBeInitializeCrossSigning()
checkSessionPushIsOn() checkSessionPushIsOn()
observeCrossSigningReset() observeCrossSigningReset()
} }
private fun cleanupFiles() {
// Mitigation: delete all cached decrypted files each time the application is started.
activeSessionHolder.getSafeActiveSession()?.fileService()?.clearDecryptedCache()
}
private fun observeCrossSigningReset() { private fun observeCrossSigningReset() {
val safeActiveSession = activeSessionHolder.getSafeActiveSession() ?: return val safeActiveSession = activeSessionHolder.getSafeActiveSession() ?: return

View file

@ -98,4 +98,7 @@ sealed class RoomDetailAction : VectorViewModelAction {
data class SetAvatarAction(val newAvatarUri: Uri, val newAvatarFileName: String) : RoomDetailAction() data class SetAvatarAction(val newAvatarUri: Uri, val newAvatarFileName: String) : RoomDetailAction()
object QuickActionSetTopic : RoomDetailAction() object QuickActionSetTopic : RoomDetailAction()
data class ShowRoomAvatarFullScreen(val matrixItem: MatrixItem?, val transitionView: View?) : RoomDetailAction() data class ShowRoomAvatarFullScreen(val matrixItem: MatrixItem?, val transitionView: View?) : RoomDetailAction()
// Preview URL
data class DoNotShowPreviewUrlFor(val eventId: String, val url: String) : RoomDetailAction()
} }

View file

@ -53,7 +53,6 @@ import androidx.lifecycle.Observer
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import butterknife.BindView
import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.OnModelBuildFinishedListener import com.airbnb.epoxy.OnModelBuildFinishedListener
import com.airbnb.epoxy.addGlidePreloader import com.airbnb.epoxy.addGlidePreloader
@ -69,6 +68,7 @@ import com.airbnb.mvrx.withState
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.jakewharton.rxbinding3.widget.textChanges import com.jakewharton.rxbinding3.widget.textChanges
import com.vanniktech.emoji.EmojiPopup
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.dialogs.ConfirmationDialogBuilder import im.vector.app.core.dialogs.ConfirmationDialogBuilder
import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper
@ -140,6 +140,7 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageInformationD
import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
import im.vector.app.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet import im.vector.app.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.detail.widget.RoomWidgetsBottomSheet import im.vector.app.features.home.room.detail.widget.RoomWidgetsBottomSheet
import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.EventHtmlRenderer
import im.vector.app.features.html.PillImageSpan import im.vector.app.features.html.PillImageSpan
@ -165,7 +166,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_detail.* import kotlinx.android.synthetic.main.fragment_room_detail.*
import kotlinx.android.synthetic.main.merge_composer_layout.view.* import kotlinx.android.synthetic.main.composer_layout.view.*
import kotlinx.android.synthetic.main.merge_overlay_waiting_view.* import kotlinx.android.synthetic.main.merge_overlay_waiting_view.*
import org.billcarsonfr.jsonviewer.JSonViewerDialog import org.billcarsonfr.jsonviewer.JSonViewerDialog
import org.commonmark.parser.Parser import org.commonmark.parser.Parser
@ -174,7 +175,6 @@ import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent
@ -185,7 +185,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
@ -194,7 +193,6 @@ import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.session.widgets.model.WidgetType
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode
import timber.log.Timber import timber.log.Timber
@ -289,8 +287,6 @@ class RoomDetailFragment @Inject constructor(
private lateinit var attachmentsHelper: AttachmentsHelper private lateinit var attachmentsHelper: AttachmentsHelper
private lateinit var keyboardStateUtils: KeyboardStateUtils private lateinit var keyboardStateUtils: KeyboardStateUtils
@BindView(R.id.composerLayout)
lateinit var composerLayout: TextComposerView
private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView
private var lockSendButton = false private var lockSendButton = false
@ -311,6 +307,7 @@ class RoomDetailFragment @Inject constructor(
setupActiveCallView() setupActiveCallView()
setupJumpToBottomView() setupJumpToBottomView()
setupConfBannerView() setupConfBannerView()
setupEmojiPopup()
roomToolbarContentView.debouncedClicks { roomToolbarContentView.debouncedClicks {
navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId) navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId)
@ -478,6 +475,20 @@ class RoomDetailFragment @Inject constructor(
} }
} }
private fun setupEmojiPopup() {
val emojiPopup = EmojiPopup
.Builder
.fromRootView(rootConstraintLayout)
.setKeyboardAnimationStyle(R.style.emoji_fade_animation_style)
.setOnEmojiPopupShownListener { composerLayout?.composerEmojiButton?.setImageResource(R.drawable.ic_keyboard) }
.setOnEmojiPopupDismissListener { composerLayout?.composerEmojiButton?.setImageResource(R.drawable.ic_insert_emoji) }
.build(composerLayout.composerEditText)
composerLayout.composerEmojiButton.debouncedClicks {
emojiPopup.toggle()
}
}
private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) { private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) {
navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo)) navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo))
} }
@ -1090,18 +1101,6 @@ class RoomDetailFragment @Inject constructor(
} }
} }
private val writingFileActivityResultLauncher = registerForPermissionsResult { allGranted ->
if (allGranted) {
val pendingUri = roomDetailViewModel.pendingUri
if (pendingUri != null) {
roomDetailViewModel.pendingUri = null
sendUri(pendingUri)
}
} else {
cleanUpAfterPermissionNotGranted()
}
}
private fun setupComposer() { private fun setupComposer() {
val composerEditText = composerLayout.composerEditText val composerEditText = composerLayout.composerEditText
autoCompleter.setup(composerEditText) autoCompleter.setup(composerEditText)
@ -1147,14 +1146,7 @@ class RoomDetailFragment @Inject constructor(
} }
override fun onRichContentSelected(contentUri: Uri): Boolean { override fun onRichContentSelected(contentUri: Uri): Boolean {
// We need WRITE_EXTERNAL permission return sendUri(contentUri)
return if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, requireActivity(), writingFileActivityResultLauncher)) {
sendUri(contentUri)
} else {
roomDetailViewModel.pendingUri = contentUri
// Always intercept when we request some permission
true
}
} }
} }
} }
@ -1185,11 +1177,9 @@ class RoomDetailFragment @Inject constructor(
} }
private fun sendUri(uri: Uri): Boolean { private fun sendUri(uri: Uri): Boolean {
roomDetailViewModel.preventAttachmentPreview = true
val shareIntent = Intent(Intent.ACTION_SEND, uri) val shareIntent = Intent(Intent.ACTION_SEND, uri)
val isHandled = attachmentsHelper.handleShareIntent(requireContext(), shareIntent) val isHandled = attachmentsHelper.handleShareIntent(requireContext(), shareIntent)
if (!isHandled) { if (!isHandled) {
roomDetailViewModel.preventAttachmentPreview = false
Toast.makeText(requireContext(), R.string.error_handling_incoming_share, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.error_handling_incoming_share, Toast.LENGTH_SHORT).show()
} }
return isHandled return isHandled
@ -1211,9 +1201,6 @@ class RoomDetailFragment @Inject constructor(
scrollOnHighlightedEventCallback.timeline = roomDetailViewModel.timeline scrollOnHighlightedEventCallback.timeline = roomDetailViewModel.timeline
timelineEventController.update(state) timelineEventController.update(state)
inviteView.visibility = View.GONE inviteView.visibility = View.GONE
val uid = session.myUserId
val meMember = state.myRoomMember()
avatarRenderer.render(MatrixItem.UserItem(uid, meMember?.displayName, meMember?.avatarUrl), composerLayout.composerAvatarImageView)
if (state.tombstoneEvent == null) { if (state.tombstoneEvent == null) {
if (state.canSendMessage) { if (state.canSendMessage) {
composerLayout.visibility = View.VISIBLE composerLayout.visibility = View.VISIBLE
@ -1554,7 +1541,6 @@ class RoomDetailFragment @Inject constructor(
private fun cleanUpAfterPermissionNotGranted() { private fun cleanUpAfterPermissionNotGranted() {
// Reset all pending data // Reset all pending data
roomDetailViewModel.pendingAction = null roomDetailViewModel.pendingAction = null
roomDetailViewModel.pendingUri = null
attachmentsHelper.pendingType = null attachmentsHelper.pendingType = null
} }
@ -1630,6 +1616,10 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.handle(itemAction) roomDetailViewModel.handle(itemAction)
} }
override fun getPreviewUrlRetriever(): PreviewUrlRetriever {
return roomDetailViewModel.previewUrlRetriever
}
override fun onRoomCreateLinkClicked(url: String) { override fun onRoomCreateLinkClicked(url: String) {
permalinkHandler permalinkHandler
.launch(requireContext(), url, object : NavigationInterceptor { .launch(requireContext(), url, object : NavigationInterceptor {
@ -1652,17 +1642,20 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.handle(RoomDetailAction.EnterTrackingUnreadMessagesState) roomDetailViewModel.handle(RoomDetailAction.EnterTrackingUnreadMessagesState)
} }
override fun onPreviewUrlClicked(url: String) {
onUrlClicked(url, url)
}
override fun onPreviewUrlCloseClicked(eventId: String, url: String) {
roomDetailViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, url))
}
private fun onShareActionClicked(action: EventSharedAction.Share) { private fun onShareActionClicked(action: EventSharedAction.Share) {
if (action.messageContent is MessageTextContent) { if (action.messageContent is MessageTextContent) {
shareText(requireContext(), action.messageContent.body) shareText(requireContext(), action.messageContent.body)
} else if (action.messageContent is MessageWithAttachmentContent) { } else if (action.messageContent is MessageWithAttachmentContent) {
session.fileService().downloadFile( session.fileService().downloadFile(
downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE, messageContent = action.messageContent,
id = action.eventId,
fileName = action.messageContent.body,
mimeType = action.messageContent.mimeType,
url = action.messageContent.getFileUrl(),
elementToDecrypt = action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
callback = object : MatrixCallback<File> { callback = object : MatrixCallback<File> {
override fun onSuccess(data: File) { override fun onSuccess(data: File) {
if (isAdded) { if (isAdded) {
@ -1692,12 +1685,7 @@ class RoomDetailFragment @Inject constructor(
return return
} }
session.fileService().downloadFile( session.fileService().downloadFile(
downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE, messageContent = action.messageContent,
id = action.eventId,
fileName = action.messageContent.body,
mimeType = action.messageContent.mimeType,
url = action.messageContent.getFileUrl(),
elementToDecrypt = action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
callback = object : MatrixCallback<File> { callback = object : MatrixCallback<File> {
override fun onSuccess(data: File) { override fun onSuccess(data: File) {
if (isAdded) { if (isAdded) {
@ -1959,24 +1947,18 @@ class RoomDetailFragment @Inject constructor(
// AttachmentsHelper.Callback // AttachmentsHelper.Callback
override fun onContentAttachmentsReady(attachments: List<ContentAttachmentData>) { override fun onContentAttachmentsReady(attachments: List<ContentAttachmentData>) {
if (roomDetailViewModel.preventAttachmentPreview) { val grouped = attachments.toGroupedContentAttachmentData()
roomDetailViewModel.preventAttachmentPreview = false if (grouped.notPreviewables.isNotEmpty()) {
roomDetailViewModel.handle(RoomDetailAction.SendMedia(attachments, false)) // Send the not previewable attachments right now (?)
} else { roomDetailViewModel.handle(RoomDetailAction.SendMedia(grouped.notPreviewables, false))
val grouped = attachments.toGroupedContentAttachmentData() }
if (grouped.notPreviewables.isNotEmpty()) { if (grouped.previewables.isNotEmpty()) {
// Send the not previewable attachments right now (?) val intent = AttachmentsPreviewActivity.newIntent(requireContext(), AttachmentsPreviewArgs(grouped.previewables))
roomDetailViewModel.handle(RoomDetailAction.SendMedia(grouped.notPreviewables, false)) contentAttachmentActivityResultLauncher.launch(intent)
}
if (grouped.previewables.isNotEmpty()) {
val intent = AttachmentsPreviewActivity.newIntent(requireContext(), AttachmentsPreviewArgs(grouped.previewables))
contentAttachmentActivityResultLauncher.launch(intent)
}
} }
} }
override fun onAttachmentsProcessFailed() { override fun onAttachmentsProcessFailed() {
roomDetailViewModel.preventAttachmentPreview = false
Toast.makeText(requireContext(), R.string.error_attachment, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.error_attachment, Toast.LENGTH_SHORT).show()
} }

View file

@ -40,6 +40,7 @@ import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator
import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder
import im.vector.app.features.home.room.detail.timeline.helper.TimelineSettingsFactory import im.vector.app.features.home.room.detail.timeline.helper.TimelineSettingsFactory
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.typing.TypingHelper import im.vector.app.features.home.room.typing.TypingHelper
import im.vector.app.features.powerlevel.PowerLevelsObservableFactory import im.vector.app.features.powerlevel.PowerLevelsObservableFactory
import im.vector.app.features.raw.wellknown.getElementWellknown import im.vector.app.features.raw.wellknown.getElementWellknown
@ -69,7 +70,6 @@ import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage
import org.matrix.android.sdk.api.session.events.model.isTextMessage import org.matrix.android.sdk.api.session.events.model.isTextMessage
import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams
@ -80,7 +80,6 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.OptionItem import org.matrix.android.sdk.api.session.room.model.message.OptionItem
import org.matrix.android.sdk.api.session.room.model.message.getFileName
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.tombstone.RoomTombstoneContent import org.matrix.android.sdk.api.session.room.model.tombstone.RoomTombstoneContent
import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper
@ -92,7 +91,6 @@ import org.matrix.android.sdk.api.session.room.timeline.getTextEditableContent
import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.session.widgets.model.WidgetType
import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode
import org.matrix.android.sdk.internal.util.awaitCallback import org.matrix.android.sdk.internal.util.awaitCallback
@ -128,15 +126,12 @@ class RoomDetailViewModel @AssistedInject constructor(
private var timelineEvents = PublishRelay.create<List<TimelineEvent>>() private var timelineEvents = PublishRelay.create<List<TimelineEvent>>()
val timeline = room.createTimeline(eventId, timelineSettings) val timeline = room.createTimeline(eventId, timelineSettings)
// Same lifecycle than the ViewModel (survive to screen rotation)
val previewUrlRetriever = PreviewUrlRetriever(session)
// Slot to keep a pending action during permission request // Slot to keep a pending action during permission request
var pendingAction: RoomDetailAction? = null 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 trackUnreadMessages = AtomicBoolean(false)
private var mostRecentDisplayedEvent: TimelineEvent? = null private var mostRecentDisplayedEvent: TimelineEvent? = null
@ -286,9 +281,14 @@ class RoomDetailViewModel @AssistedInject constructor(
RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView) RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView)
) )
} }
is RoomDetailAction.DoNotShowPreviewUrlFor -> handleDoNotShowPreviewUrlFor(action)
}.exhaustive }.exhaustive
} }
private fun handleDoNotShowPreviewUrlFor(action: RoomDetailAction.DoNotShowPreviewUrlFor) {
previewUrlRetriever.doNotShowPreviewUrlFor(action.eventId, action.url)
}
private fun handleSetNewAvatar(action: RoomDetailAction.SetAvatarAction) { private fun handleSetNewAvatar(action: RoomDetailAction.SetAvatarAction) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@ -1021,10 +1021,10 @@ class RoomDetailViewModel @AssistedInject constructor(
} }
private fun handleOpenOrDownloadFile(action: RoomDetailAction.DownloadOrOpen) { private fun handleOpenOrDownloadFile(action: RoomDetailAction.DownloadOrOpen) {
val mxcUrl = action.messageFileContent.getFileUrl() val mxcUrl = action.messageFileContent.getFileUrl() ?: return
val isLocalSendingFile = action.senderId == session.myUserId val isLocalSendingFile = action.senderId == session.myUserId
&& mxcUrl?.startsWith("content://") ?: false && mxcUrl.startsWith("content://")
val isDownloaded = mxcUrl?.let { session.fileService().isFileInCache(it, action.messageFileContent.mimeType) } ?: false val isDownloaded = session.fileService().isFileInCache(action.messageFileContent)
if (isLocalSendingFile) { if (isLocalSendingFile) {
tryOrNull { Uri.parse(mxcUrl) }?.let { tryOrNull { Uri.parse(mxcUrl) }?.let {
_viewEvents.post(RoomDetailViewEvents.OpenFile( _viewEvents.post(RoomDetailViewEvents.OpenFile(
@ -1035,7 +1035,7 @@ class RoomDetailViewModel @AssistedInject constructor(
} }
} else if (isDownloaded) { } else if (isDownloaded) {
// we can open it // we can open it
session.fileService().getTemporarySharableURI(mxcUrl!!, action.messageFileContent.mimeType)?.let { uri -> session.fileService().getTemporarySharableURI(action.messageFileContent)?.let { uri ->
_viewEvents.post(RoomDetailViewEvents.OpenFile( _viewEvents.post(RoomDetailViewEvents.OpenFile(
action.messageFileContent.mimeType, action.messageFileContent.mimeType,
uri, uri,
@ -1044,12 +1044,7 @@ class RoomDetailViewModel @AssistedInject constructor(
} }
} else { } else {
session.fileService().downloadFile( session.fileService().downloadFile(
downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE, messageContent = action.messageFileContent,
id = action.eventId,
fileName = action.messageFileContent.getFileName(),
mimeType = action.messageFileContent.mimeType,
url = mxcUrl,
elementToDecrypt = action.messageFileContent.encryptedFileInfo?.toElementToDecrypt(),
callback = object : MatrixCallback<File> { callback = object : MatrixCallback<File> {
override fun onSuccess(data: File) { override fun onSuccess(data: File) {
_viewEvents.post(RoomDetailViewEvents.DownloadFileState( _viewEvents.post(RoomDetailViewEvents.DownloadFileState(
@ -1361,6 +1356,17 @@ class RoomDetailViewModel @AssistedInject constructor(
override fun onTimelineUpdated(snapshot: List<TimelineEvent>) { override fun onTimelineUpdated(snapshot: List<TimelineEvent>) {
timelineEvents.accept(snapshot) timelineEvents.accept(snapshot)
// PreviewUrl
if (vectorPreferences.showUrlPreviews()) {
withState { state ->
snapshot
.takeIf { state.asyncRoomSummary.invoke()?.isEncrypted == false }
?.forEach {
previewUrlRetriever.getPreviewUrl(it.root, viewModelScope)
}
}
}
} }
override fun onTimelineFailure(throwable: Throwable) { override fun onTimelineFailure(throwable: Throwable) {

View file

@ -24,16 +24,16 @@ import android.text.Editable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnection
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.view.inputmethod.InputConnectionCompat
import com.vanniktech.emoji.EmojiEditText
import im.vector.app.core.extensions.ooi import im.vector.app.core.extensions.ooi
import im.vector.app.core.platform.SimpleTextWatcher import im.vector.app.core.platform.SimpleTextWatcher
import im.vector.app.features.html.PillImageSpan import im.vector.app.features.html.PillImageSpan
import timber.log.Timber import timber.log.Timber
class ComposerEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = android.R.attr.editTextStyle) class ComposerEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = android.R.attr.editTextStyle)
: AppCompatEditText(context, attrs, defStyleAttr) { : EmojiEditText(context, attrs, defStyleAttr) {
interface Callback { interface Callback {
fun onRichContentSelected(contentUri: Uri): Boolean fun onRichContentSelected(contentUri: Uri): Boolean

View file

@ -36,7 +36,7 @@ import androidx.transition.TransitionSet
import butterknife.BindView import butterknife.BindView
import butterknife.ButterKnife import butterknife.ButterKnife
import im.vector.app.R import im.vector.app.R
import kotlinx.android.synthetic.main.merge_composer_layout.view.* import kotlinx.android.synthetic.main.composer_layout.view.*
import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel
/** /**
@ -72,8 +72,8 @@ class TextComposerView @JvmOverloads constructor(context: Context, attrs: Attrib
@BindView(R.id.composerEditText) @BindView(R.id.composerEditText)
lateinit var composerEditText: ComposerEditText lateinit var composerEditText: ComposerEditText
@BindView(R.id.composer_avatar_view) @BindView(R.id.composer_emoji)
lateinit var composerAvatarImageView: ImageView lateinit var composerEmojiButton: ImageButton
@BindView(R.id.composer_shield) @BindView(R.id.composer_shield)
lateinit var composerShieldImageView: ImageView lateinit var composerShieldImageView: ImageView
@ -86,7 +86,7 @@ class TextComposerView @JvmOverloads constructor(context: Context, attrs: Attrib
get() = composerEditText.text get() = composerEditText.text
init { init {
inflate(context, R.layout.merge_composer_layout, this) inflate(context, R.layout.composer_layout, this)
ButterKnife.bind(this) ButterKnife.bind(this)
collapse(false) collapse(false)
composerEditText.callback = object : ComposerEditText.Callback { composerEditText.callback = object : ComposerEditText.Callback {
@ -110,20 +110,20 @@ class TextComposerView @JvmOverloads constructor(context: Context, attrs: Attrib
} }
fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) { fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) {
if (currentConstraintSetId == R.layout.constraint_set_composer_layout_compact) { if (currentConstraintSetId == R.layout.composer_layout_constraint_set_compact) {
// ignore we good // ignore we good
return return
} }
currentConstraintSetId = R.layout.constraint_set_composer_layout_compact currentConstraintSetId = R.layout.composer_layout_constraint_set_compact
applyNewConstraintSet(animate, transitionComplete) applyNewConstraintSet(animate, transitionComplete)
} }
fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) { fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) {
if (currentConstraintSetId == R.layout.constraint_set_composer_layout_expanded) { if (currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded) {
// ignore we good // ignore we good
return return
} }
currentConstraintSetId = R.layout.constraint_set_composer_layout_expanded currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded
applyNewConstraintSet(animate, transitionComplete) applyNewConstraintSet(animate, transitionComplete)
} }

View file

@ -48,6 +48,7 @@ import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
import im.vector.app.features.home.room.detail.timeline.item.TimelineReadMarkerItem_ import im.vector.app.features.home.room.detail.timeline.item.TimelineReadMarkerItem_
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
@ -76,7 +77,13 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
private val backgroundHandler: Handler private val backgroundHandler: Handler
) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor { ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor {
interface Callback : BaseCallback, ReactionPillCallback, AvatarCallback, UrlClickCallback, ReadReceiptsCallback { interface Callback :
BaseCallback,
ReactionPillCallback,
AvatarCallback,
UrlClickCallback,
ReadReceiptsCallback,
PreviewUrlCallback {
fun onLoadMore(direction: Timeline.Direction) fun onLoadMore(direction: Timeline.Direction)
fun onEventInvisible(event: TimelineEvent) fun onEventInvisible(event: TimelineEvent)
fun onEventVisible(event: TimelineEvent) fun onEventVisible(event: TimelineEvent)
@ -91,6 +98,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
// TODO move all callbacks to this? // TODO move all callbacks to this?
fun onTimelineItemAction(itemAction: RoomDetailAction) fun onTimelineItemAction(itemAction: RoomDetailAction)
fun getPreviewUrlRetriever(): PreviewUrlRetriever
} }
interface ReactionPillCallback { interface ReactionPillCallback {
@ -118,6 +127,11 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
fun onUrlLongClicked(url: String): Boolean fun onUrlLongClicked(url: String): Boolean
} }
interface PreviewUrlCallback {
fun onPreviewUrlClicked(url: String)
fun onPreviewUrlCloseClicked(eventId: String, url: String)
}
// Map eventId to adapter position // Map eventId to adapter position
private val adapterPositionMapping = HashMap<String, Int>() private val adapterPositionMapping = HashMap<String, Int>()
private val modelCache = arrayListOf<CacheItemData?>() private val modelCache = arrayListOf<CacheItemData?>()

View file

@ -82,10 +82,9 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat
when (cryptoError) { when (cryptoError) {
MXCryptoError.ErrorType.KEYS_WITHHELD -> { MXCryptoError.ErrorType.KEYS_WITHHELD -> {
span { span {
apply { drawableProvider.getDrawable(R.drawable.ic_forbidden, colorFromAttribute)?.let {
drawableProvider.getDrawable(R.drawable.ic_forbidden, colorFromAttribute)?.let { image(it, "baseline")
image(it, "baseline") +" "
}
} }
span(stringProvider.getString(R.string.notice_crypto_unable_to_decrypt_final)) { span(stringProvider.getString(R.string.notice_crypto_unable_to_decrypt_final)) {
textStyle = "italic" textStyle = "italic"
@ -95,10 +94,9 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat
} }
else -> { else -> {
span { span {
apply { drawableProvider.getDrawable(R.drawable.ic_clock, colorFromAttribute)?.let {
drawableProvider.getDrawable(R.drawable.ic_clock, colorFromAttribute)?.let { image(it, "baseline")
image(it, "baseline") +" "
}
} }
span(stringProvider.getString(R.string.notice_crypto_unable_to_decrypt_friendly)) { span(stringProvider.getString(R.string.notice_crypto_unable_to_decrypt_friendly)) {
textStyle = "italic" textStyle = "italic"

View file

@ -84,9 +84,11 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVerification
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS
import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_POLL import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_POLL
import org.matrix.android.sdk.api.session.room.model.message.getFileName
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
import javax.inject.Inject import javax.inject.Inject
@ -144,16 +146,16 @@ class MessageItemFactory @Inject constructor(
// val all = event.root.toContent() // val all = event.root.toContent()
// val ev = all.toModel<Event>() // val ev = all.toModel<Event>()
return when (messageContent) { return when (messageContent) {
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageTextContent -> buildItemForTextContent(messageContent, informationData, highlight, callback, attributes) is MessageTextContent -> buildItemForTextContent(messageContent, informationData, highlight, callback, attributes)
is MessageImageInfoContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageImageInfoContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes) is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes)
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, attributes) is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, attributes)
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessagePollResponseContent -> noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback) is MessagePollResponseContent -> noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback)
else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes)
} }
} }
@ -164,7 +166,7 @@ class MessageItemFactory @Inject constructor(
callback: TimelineEventController.Callback?, callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): VectorEpoxyModel<*>? { attributes: AbsMessageItem.Attributes): VectorEpoxyModel<*>? {
return when (messageContent.optionType) { return when (messageContent.optionType) {
OPTION_TYPE_POLL -> { OPTION_TYPE_POLL -> {
MessagePollItem_() MessagePollItem_()
.attributes(attributes) .attributes(attributes)
.callback(callback) .callback(callback)
@ -204,7 +206,12 @@ class MessageItemFactory @Inject constructor(
return MessageFileItem_() return MessageFileItem_()
.attributes(attributes) .attributes(attributes)
.izLocalFile(fileUrl.isLocalFile()) .izLocalFile(fileUrl.isLocalFile())
.izDownloaded(session.fileService().isFileInCache(fileUrl, messageContent.mimeType)) .izDownloaded(session.fileService().isFileInCache(
fileUrl,
messageContent.getFileName(),
messageContent.mimeType,
messageContent.encryptedFileInfo?.toElementToDecrypt())
)
.mxcUrl(fileUrl) .mxcUrl(fileUrl)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder) .contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
@ -264,7 +271,7 @@ class MessageItemFactory @Inject constructor(
.attributes(attributes) .attributes(attributes)
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.izLocalFile(messageContent.getFileUrl().isLocalFile()) .izLocalFile(messageContent.getFileUrl().isLocalFile())
.izDownloaded(session.fileService().isFileInCache(mxcUrl, messageContent.mimeType)) .izDownloaded(session.fileService().isFileInCache(messageContent))
.mxcUrl(mxcUrl) .mxcUrl(mxcUrl)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder) .contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
@ -305,7 +312,7 @@ class MessageItemFactory @Inject constructor(
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.imageContentRenderer(imageContentRenderer) .imageContentRenderer(imageContentRenderer)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.playable(messageContent.info?.mimeType == "image/gif") .playable(messageContent.info?.mimeType == MimeTypes.Gif)
.highlighted(highlight) .highlighted(highlight)
.mediaData(data) .mediaData(data)
.apply { .apply {
@ -371,7 +378,7 @@ class MessageItemFactory @Inject constructor(
val codeVisitor = CodeVisitor() val codeVisitor = CodeVisitor()
codeVisitor.visit(localFormattedBody) codeVisitor.visit(localFormattedBody)
when (codeVisitor.codeKind) { when (codeVisitor.codeKind) {
CodeVisitor.Kind.BLOCK -> { CodeVisitor.Kind.BLOCK -> {
val codeFormattedBlock = htmlRenderer.get().render(localFormattedBody) val codeFormattedBlock = htmlRenderer.get().render(localFormattedBody)
if (codeFormattedBlock == null) { if (codeFormattedBlock == null) {
buildFormattedTextItem(messageContent, informationData, highlight, callback, attributes) buildFormattedTextItem(messageContent, informationData, highlight, callback, attributes)
@ -387,7 +394,7 @@ class MessageItemFactory @Inject constructor(
buildMessageTextItem(codeFormatted, false, informationData, highlight, callback, attributes) buildMessageTextItem(codeFormatted, false, informationData, highlight, callback, attributes)
} }
} }
CodeVisitor.Kind.NONE -> { CodeVisitor.Kind.NONE -> {
buildFormattedTextItem(messageContent, informationData, highlight, callback, attributes) buildFormattedTextItem(messageContent, informationData, highlight, callback, attributes)
} }
} }
@ -424,6 +431,9 @@ class MessageItemFactory @Inject constructor(
} }
.useBigFont(linkifiedBody.length <= MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT * 2 && containsOnlyEmojis(linkifiedBody.toString())) .useBigFont(linkifiedBody.length <= MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT * 2 && containsOnlyEmojis(linkifiedBody.toString()))
.searchForPills(isFormatted) .searchForPills(isFormatted)
.previewUrlRetriever(callback?.getPreviewUrlRetriever())
.imageContentRenderer(imageContentRenderer)
.previewUrlCallback(callback)
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.attributes(attributes) .attributes(attributes)
.highlighted(highlight) .highlighted(highlight)
@ -529,6 +539,9 @@ class MessageItemFactory @Inject constructor(
} }
} }
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.previewUrlRetriever(callback?.getPreviewUrlRetriever())
.imageContentRenderer(imageContentRenderer)
.previewUrlCallback(callback)
.attributes(attributes) .attributes(attributes)
.highlighted(highlight) .highlighted(highlight)
.movementMethod(createLinkMovementMethod(callback)) .movementMethod(createLinkMovementMethod(callback))

View file

@ -1,19 +1,17 @@
/* /*
* Copyright 2019 New Vector Ltd
* Copyright 2019 New Vector Ltd *
* * Licensed under the Apache License, Version 2.0 (the "License");
* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License.
* you may not use this file except in compliance with the License. * You may obtain a copy of the License at
* You may obtain a copy of the License at *
* * http://www.apache.org/licenses/LICENSE-2.0
* http://www.apache.org/licenses/LICENSE-2.0 *
* * Unless required by applicable law or agreed to in writing, software
* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS,
* 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. * See the License for the specific language governing permissions and
* See the License for the specific language governing permissions and * limitations under the License.
* limitations under the License.
*/ */
package im.vector.app.features.home.room.detail.timeline.helper package im.vector.app.features.home.room.detail.timeline.helper

View file

@ -1,19 +1,17 @@
/* /*
* Copyright 2019 New Vector Ltd
* Copyright 2019 New Vector Ltd *
* * Licensed under the Apache License, Version 2.0 (the "License");
* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License.
* you may not use this file except in compliance with the License. * You may obtain a copy of the License at
* You may obtain a copy of the License at *
* * http://www.apache.org/licenses/LICENSE-2.0
* http://www.apache.org/licenses/LICENSE-2.0 *
* * Unless required by applicable law or agreed to in writing, software
* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS,
* 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. * See the License for the specific language governing permissions and
* See the License for the specific language governing permissions and * limitations under the License.
* limitations under the License.
*/ */
package im.vector.app.features.home.room.detail.timeline.helper package im.vector.app.features.home.room.detail.timeline.helper

View file

@ -23,7 +23,12 @@ import androidx.core.widget.TextViewCompat
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.tools.findPillsAndProcess import im.vector.app.features.home.room.detail.timeline.tools.findPillsAndProcess
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlUiState
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlView
import im.vector.app.features.media.ImageContentRenderer
@EpoxyModelClass(layout = R.layout.item_timeline_event_base) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() { abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@ -37,10 +42,27 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
var useBigFont: Boolean = false var useBigFont: Boolean = false
@EpoxyAttribute
var previewUrlRetriever: PreviewUrlRetriever? = null
@EpoxyAttribute
var previewUrlCallback: TimelineEventController.PreviewUrlCallback? = null
@EpoxyAttribute
var imageContentRenderer: ImageContentRenderer? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var movementMethod: MovementMethod? = null var movementMethod: MovementMethod? = null
private val previewUrlViewUpdater = PreviewUrlViewUpdater()
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
// Preview URL
previewUrlViewUpdater.previewUrlView = holder.previewUrlView
previewUrlViewUpdater.imageContentRenderer = imageContentRenderer
previewUrlRetriever?.addListener(attributes.informationData.eventId, previewUrlViewUpdater)
holder.previewUrlView.delegate = previewUrlCallback
if (useBigFont) { if (useBigFont) {
holder.messageView.textSize = 44F holder.messageView.textSize = 44F
} else { } else {
@ -65,12 +87,29 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
holder.messageView.setTextFuture(textFuture) holder.messageView.setTextFuture(textFuture)
} }
override fun unbind(holder: Holder) {
super.unbind(holder)
previewUrlViewUpdater.previewUrlView = null
previewUrlViewUpdater.imageContentRenderer = null
previewUrlRetriever?.removeListener(attributes.informationData.eventId, previewUrlViewUpdater)
}
override fun getViewType() = STUB_ID override fun getViewType() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) { class Holder : AbsMessageItem.Holder(STUB_ID) {
val messageView by bind<AppCompatTextView>(R.id.messageTextView) val messageView by bind<AppCompatTextView>(R.id.messageTextView)
val previewUrlView by bind<PreviewUrlView>(R.id.messageUrlPreview)
} }
inner class PreviewUrlViewUpdater : PreviewUrlRetriever.PreviewUrlRetrieverListener {
var previewUrlView: PreviewUrlView? = null
var imageContentRenderer: ImageContentRenderer? = null
override fun onStateUpdated(state: PreviewUrlUiState) {
val safeImageContentRenderer = imageContentRenderer ?: return
previewUrlView?.render(state, safeImageContentRenderer)
}
}
companion object { companion object {
private const val STUB_ID = R.id.messageContentTextStub private const val STUB_ID = R.id.messageContentTextStub
} }

View file

@ -0,0 +1,127 @@
/*
* 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.app.features.home.room.detail.timeline.url
import im.vector.app.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.cache.CacheStrategy
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.Event
class PreviewUrlRetriever(session: Session) {
private val mediaService = session.mediaService()
private val data = mutableMapOf<String, PreviewUrlUiState>()
private val listeners = mutableMapOf<String, MutableSet<PreviewUrlRetrieverListener>>()
// In memory list
private val blockedUrl = mutableSetOf<String>()
fun getPreviewUrl(event: Event, coroutineScope: CoroutineScope) {
val eventId = event.eventId ?: return
synchronized(data) {
if (data[eventId] == null) {
// Keep only the first URL for the moment
val url = mediaService.extractUrls(event)
.firstOrNull()
?.takeIf { it !in blockedUrl }
if (url == null) {
updateState(eventId, PreviewUrlUiState.NoUrl)
} else {
updateState(eventId, PreviewUrlUiState.Loading)
}
url
} else {
// Already handled
null
}
}?.let { urlToRetrieve ->
coroutineScope.launch {
runCatching {
mediaService.getPreviewUrl(
url = urlToRetrieve,
timestamp = null,
cacheStrategy = if (BuildConfig.DEBUG) CacheStrategy.NoCache else CacheStrategy.TtlCache(CACHE_VALIDITY, false)
)
}.fold(
{
synchronized(data) {
// Blocked after the request has been sent?
if (urlToRetrieve in blockedUrl) {
updateState(eventId, PreviewUrlUiState.NoUrl)
} else {
updateState(eventId, PreviewUrlUiState.Data(eventId, urlToRetrieve, it))
}
}
},
{
synchronized(data) {
updateState(eventId, PreviewUrlUiState.Error(it))
}
}
)
}
}
}
fun doNotShowPreviewUrlFor(eventId: String, url: String) {
blockedUrl.add(url)
// Notify the listener
synchronized(data) {
data[eventId]
?.takeIf { it is PreviewUrlUiState.Data && it.url == url }
?.let {
updateState(eventId, PreviewUrlUiState.NoUrl)
}
}
}
private fun updateState(eventId: String, state: PreviewUrlUiState) {
data[eventId] = state
// Notify the listener
listeners[eventId].orEmpty().forEach {
it.onStateUpdated(state)
}
}
// Called by the Epoxy item during binding
fun addListener(key: String, listener: PreviewUrlRetrieverListener) {
listeners.getOrPut(key) { mutableSetOf() }.add(listener)
// Give the current state if any
synchronized(data) {
listener.onStateUpdated(data[key] ?: PreviewUrlUiState.Unknown)
}
}
// Called by the Epoxy item during unbinding
fun removeListener(key: String, listener: PreviewUrlRetrieverListener) {
listeners[key]?.remove(listener)
}
interface PreviewUrlRetrieverListener {
fun onStateUpdated(state: PreviewUrlUiState)
}
companion object {
// One week in millis
private const val CACHE_VALIDITY: Long = 7 * 24 * 3_600 * 1_000
}
}

View file

@ -0,0 +1,41 @@
/*
* 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.app.features.home.room.detail.timeline.url
import org.matrix.android.sdk.api.session.media.PreviewUrlData
/**
* The state representing a preview url UI state for an Event
*/
sealed class PreviewUrlUiState {
// No info
object Unknown : PreviewUrlUiState()
// The event does not contain any URLs
object NoUrl : PreviewUrlUiState()
// Loading
object Loading : PreviewUrlUiState()
// Error
data class Error(val throwable: Throwable) : PreviewUrlUiState()
// PreviewUrl data
data class Data(val eventId: String,
val url: String,
val previewUrlData: PreviewUrlData) : PreviewUrlUiState()
}

View file

@ -0,0 +1,141 @@
/*
* 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.app.features.home.room.detail.timeline.url
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import butterknife.BindView
import butterknife.ButterKnife
import im.vector.app.R
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.media.ImageContentRenderer
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.media.PreviewUrlData
/**
* A View to display a PreviewUrl and some other state
*/
class PreviewUrlView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), View.OnClickListener {
@BindView(R.id.url_preview_title)
lateinit var titleView: TextView
@BindView(R.id.url_preview_image)
lateinit var imageView: ImageView
@BindView(R.id.url_preview_description)
lateinit var descriptionView: TextView
@BindView(R.id.url_preview_site)
lateinit var siteView: TextView
@BindView(R.id.url_preview_close)
lateinit var closeView: View
var delegate: TimelineEventController.PreviewUrlCallback? = null
init {
setupView()
}
private var state: PreviewUrlUiState = PreviewUrlUiState.Unknown
/**
* This methods is responsible for rendering the view according to the newState
*
* @param newState the newState representing the view
*/
fun render(newState: PreviewUrlUiState,
imageContentRenderer: ImageContentRenderer,
force: Boolean = false) {
if (newState == state && !force) {
return
}
state = newState
hideAll()
when (newState) {
PreviewUrlUiState.Unknown,
PreviewUrlUiState.NoUrl -> renderHidden()
PreviewUrlUiState.Loading -> renderLoading()
is PreviewUrlUiState.Error -> renderHidden()
is PreviewUrlUiState.Data -> renderData(newState.previewUrlData, imageContentRenderer)
}
}
override fun onClick(v: View?) {
when (val finalState = state) {
is PreviewUrlUiState.Data -> delegate?.onPreviewUrlClicked(finalState.url)
else -> Unit
}
}
private fun onCloseClick() {
when (val finalState = state) {
is PreviewUrlUiState.Data -> delegate?.onPreviewUrlCloseClicked(finalState.eventId, finalState.url)
else -> Unit
}
}
// PRIVATE METHODS ****************************************************************************************************************************************
private fun setupView() {
inflate(context, R.layout.url_preview, this)
ButterKnife.bind(this)
setOnClickListener(this)
closeView.setOnClickListener { onCloseClick() }
}
private fun renderHidden() {
isVisible = false
}
private fun renderLoading() {
// Just hide for the moment
isVisible = false
}
private fun renderData(previewUrlData: PreviewUrlData, imageContentRenderer: ImageContentRenderer) {
isVisible = true
titleView.setTextOrHide(previewUrlData.title)
imageView.isVisible = previewUrlData.mxcUrl?.let { imageContentRenderer.render(it, imageView) }.orFalse()
descriptionView.setTextOrHide(previewUrlData.description)
siteView.setTextOrHide(previewUrlData.siteName.takeIf { it != previewUrlData.title })
}
/**
* Hide all views that are not visible in all state
*/
private fun hideAll() {
titleView.isVisible = false
imageView.isVisible = false
descriptionView.isVisible = false
siteView.isVisible = false
}
}

View file

@ -153,12 +153,10 @@ abstract class BaseAttachmentProvider<Type>(
} else { } else {
target.onVideoFileLoading(info.uid) target.onVideoFileLoading(info.uid)
fileService.downloadFile( fileService.downloadFile(
downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
id = data.eventId,
mimeType = data.mimeType,
elementToDecrypt = data.elementToDecrypt,
fileName = data.filename, fileName = data.filename,
mimeType = data.mimeType,
url = data.url, url = data.url,
elementToDecrypt = data.elementToDecrypt,
callback = object : MatrixCallback<File> { callback = object : MatrixCallback<File> {
override fun onSuccess(data: File) { override fun onSuccess(data: File) {
target.onVideoFileReady(info.uid, data) target.onVideoFileReady(info.uid, data)

View file

@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.file.FileService import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.api.session.room.Room import org.matrix.android.sdk.api.session.room.Room
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.MimeTypes
import java.io.File import java.io.File
class DataAttachmentRoomProvider( class DataAttachmentRoomProvider(
@ -38,7 +39,7 @@ class DataAttachmentRoomProvider(
return getItem(position).let { return getItem(position).let {
when (it) { when (it) {
is ImageContentRenderer.Data -> { is ImageContentRenderer.Data -> {
if (it.mimeType == "image/gif") { if (it.mimeType == MimeTypes.Gif) {
AttachmentInfo.AnimatedImage( AttachmentInfo.AnimatedImage(
uid = it.eventId, uid = it.eventId,
url = it.url ?: "", url = it.url ?: "",
@ -77,11 +78,9 @@ class DataAttachmentRoomProvider(
override fun getFileForSharing(position: Int, callback: (File?) -> Unit) { override fun getFileForSharing(position: Int, callback: (File?) -> Unit) {
val item = getItem(position) val item = getItem(position)
fileService.downloadFile( fileService.downloadFile(
downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
id = item.eventId,
fileName = item.filename, fileName = item.filename,
mimeType = item.mimeType, mimeType = item.mimeType,
url = item.url ?: "", url = item.url,
elementToDecrypt = item.elementToDecrypt, elementToDecrypt = item.elementToDecrypt,
callback = object : MatrixCallback<File> { callback = object : MatrixCallback<File> {
override fun onSuccess(data: File) { override fun onSuccess(data: File) {

View file

@ -23,6 +23,7 @@ import android.view.View
import android.widget.ImageView import android.widget.ImageView
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestListener
@ -83,6 +84,19 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
STICKER STICKER
} }
/**
* For url preview
*/
fun render(mxcUrl: String, imageView: ImageView): Boolean {
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
val imageUrl = contentUrlResolver.resolveFullSize(mxcUrl) ?: return false
GlideApp.with(imageView)
.load(imageUrl)
.into(imageView)
return true
}
/** /**
* For gallery * For gallery
*/ */
@ -129,6 +143,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
GlideApp GlideApp
.with(contextView) .with(contextView)
.load(data) .load(data)
.diskCacheStrategy(DiskCacheStrategy.NONE)
} else { } else {
// Clear image // Clear image
val resolvedUrl = resolveUrl(data) val resolvedUrl = resolveUrl(data)
@ -183,6 +198,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
GlideApp GlideApp
.with(imageView) .with(imageView)
.load(data) .load(data)
.diskCacheStrategy(DiskCacheStrategy.NONE)
} else { } else {
// Clear image // Clear image
val resolvedUrl = resolveUrl(data) val resolvedUrl = resolveUrl(data)
@ -214,20 +230,22 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
.into(imageView) .into(imageView)
} }
fun createGlideRequest(data: Data, mode: Mode, imageView: ImageView, size: Size): GlideRequest<Drawable> { private fun createGlideRequest(data: Data, mode: Mode, imageView: ImageView, size: Size): GlideRequest<Drawable> {
return createGlideRequest(data, mode, GlideApp.with(imageView), size) return createGlideRequest(data, mode, GlideApp.with(imageView), size)
} }
fun createGlideRequest(data: Data, mode: Mode, glideRequests: GlideRequests, size: Size = processSize(data, mode)): GlideRequest<Drawable> { fun createGlideRequest(data: Data, mode: Mode, glideRequests: GlideRequests, size: Size = processSize(data, mode)): GlideRequest<Drawable> {
return if (data.elementToDecrypt != null) { return if (data.elementToDecrypt != null) {
// Encrypted image // Encrypted image
glideRequests.load(data) glideRequests
.load(data)
.diskCacheStrategy(DiskCacheStrategy.NONE)
} else { } else {
// Clear image // Clear image
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
val resolvedUrl = when (mode) { val resolvedUrl = when (mode) {
Mode.FULL_SIZE, Mode.FULL_SIZE,
Mode.STICKER -> resolveUrl(data) Mode.STICKER -> resolveUrl(data)
Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, size.width, size.height, ContentUrlResolver.ThumbnailMethod.SCALE) Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, size.width, size.height, ContentUrlResolver.ThumbnailMethod.SCALE)
} }
// Fallback to base url // Fallback to base url
@ -295,7 +313,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
finalHeight = min(maxImageWidth * height / width, maxImageHeight) finalHeight = min(maxImageWidth * height / width, maxImageHeight)
finalWidth = finalHeight * width / height finalWidth = finalHeight * width / height
} }
Mode.STICKER -> { Mode.STICKER -> {
// limit on width // limit on width
val maxWidthDp = min(dimensionConverter.dpToPx(120), maxImageWidth / 2) val maxWidthDp = min(dimensionConverter.dpToPx(120), maxImageWidth / 2)
finalWidth = min(dimensionConverter.dpToPx(width), maxWidthDp) finalWidth = min(dimensionConverter.dpToPx(width), maxWidthDp)

View file

@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
import java.io.File import java.io.File
@ -56,7 +57,7 @@ class RoomEventsAttachmentProvider(
allowNonMxcUrls = it.root.sendState.isSending() allowNonMxcUrls = it.root.sendState.isSending()
) )
if (content.mimeType == "image/gif") { if (content.mimeType == MimeTypes.Gif) {
AttachmentInfo.AnimatedImage( AttachmentInfo.AnimatedImage(
uid = it.eventId, uid = it.eventId,
url = content.url ?: "", url = content.url ?: "",
@ -125,8 +126,6 @@ class RoomEventsAttachmentProvider(
as? MessageWithAttachmentContent as? MessageWithAttachmentContent
?: return@let ?: return@let
fileService.downloadFile( fileService.downloadFile(
downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
id = timelineEvent.eventId,
fileName = messageContent.body, fileName = messageContent.body,
mimeType = messageContent.mimeType, mimeType = messageContent.mimeType,
url = messageContent.getFileUrl(), url = messageContent.getFileUrl(),

View file

@ -27,7 +27,6 @@ import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.utils.isLocalFile import im.vector.app.core.utils.isLocalFile
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
@ -76,8 +75,6 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
activeSessionHolder.getActiveSession().fileService() activeSessionHolder.getActiveSession().fileService()
.downloadFile( .downloadFile(
downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
id = data.eventId,
fileName = data.filename, fileName = data.filename,
mimeType = data.mimeType, mimeType = data.mimeType,
url = data.url, url = data.url,
@ -116,8 +113,6 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
activeSessionHolder.getActiveSession().fileService() activeSessionHolder.getActiveSession().fileService()
.downloadFile( .downloadFile(
downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
id = data.eventId,
fileName = data.filename, fileName = data.filename,
mimeType = data.mimeType, mimeType = data.mimeType,
url = data.url, url = data.url,

View file

@ -46,6 +46,7 @@ import okhttp3.Response
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.util.MimeTypes
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -274,7 +275,7 @@ class BugReporter @Inject constructor(
// add the gzipped files // add the gzipped files
for (file in gzippedFiles) { for (file in gzippedFiles) {
builder.addFormDataPart("compressed-log", file.name, file.asRequestBody("application/octet-stream".toMediaTypeOrNull())) builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()))
} }
mBugReportFiles.addAll(gzippedFiles) mBugReportFiles.addAll(gzippedFiles)
@ -295,7 +296,7 @@ class BugReporter @Inject constructor(
} }
builder.addFormDataPart("file", builder.addFormDataPart("file",
logCatScreenshotFile.name, logCatScreenshotFile.asRequestBody("application/octet-stream".toMediaTypeOrNull())) logCatScreenshotFile.name, logCatScreenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## sendBugReport() : fail to write screenshot$e") Timber.e(e, "## sendBugReport() : fail to write screenshot$e")
} }

View file

@ -30,10 +30,7 @@ import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
import org.matrix.android.sdk.internal.util.awaitCallback import org.matrix.android.sdk.internal.util.awaitCallback
import org.matrix.android.sdk.rx.rx import org.matrix.android.sdk.rx.rx
import org.matrix.android.sdk.rx.unwrap import org.matrix.android.sdk.rx.unwrap
@ -134,12 +131,7 @@ class RoomUploadsViewModel @AssistedInject constructor(
try { try {
val file = awaitCallback<File> { val file = awaitCallback<File> {
session.fileService().downloadFile( session.fileService().downloadFile(
downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE, messageContent = action.uploadEvent.contentWithAttachmentContent,
id = action.uploadEvent.eventId,
fileName = action.uploadEvent.contentWithAttachmentContent.body,
url = action.uploadEvent.contentWithAttachmentContent.getFileUrl(),
mimeType = action.uploadEvent.contentWithAttachmentContent.mimeType,
elementToDecrypt = action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(),
callback = it callback = it
) )
} }
@ -155,12 +147,7 @@ class RoomUploadsViewModel @AssistedInject constructor(
try { try {
val file = awaitCallback<File> { val file = awaitCallback<File> {
session.fileService().downloadFile( session.fileService().downloadFile(
downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE, messageContent = action.uploadEvent.contentWithAttachmentContent,
id = action.uploadEvent.eventId,
fileName = action.uploadEvent.contentWithAttachmentContent.body,
mimeType = action.uploadEvent.contentWithAttachmentContent.mimeType,
url = action.uploadEvent.contentWithAttachmentContent.getFileUrl(),
elementToDecrypt = action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(),
callback = it) callback = it)
} }
_viewEvents.post(RoomUploadsViewEvents.FileReadyForSaving(file, action.uploadEvent.contentWithAttachmentContent.body)) _viewEvents.post(RoomUploadsViewEvents.FileReadyForSaving(file, action.uploadEvent.contentWithAttachmentContent.body))

View file

@ -783,6 +783,15 @@ class VectorPreferences @Inject constructor(private val context: Context) {
return defaultPrefs.getBoolean(SETTINGS_USE_ANALYTICS_KEY, false) return defaultPrefs.getBoolean(SETTINGS_USE_ANALYTICS_KEY, false)
} }
/**
* Tells if the user wants to see URL previews in the timeline
*
* @return true if the user wants to see URL previews in the timeline
*/
fun showUrlPreviews(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_SHOW_URL_PREVIEW_KEY, true)
}
/** /**
* Enable or disable the analytics tracking. * Enable or disable the analytics tracking.
* *

View file

@ -22,7 +22,6 @@ import android.widget.CheckedTextView
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.SwitchPreference
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.restart import im.vector.app.core.extensions.restart
import im.vector.app.core.preference.VectorListPreference import im.vector.app.core.preference.VectorListPreference
@ -64,9 +63,9 @@ class VectorSettingsPreferencesFragment @Inject constructor(
} }
// Url preview // Url preview
/*
TODO Note: we keep the setting client side for now
findPreference<SwitchPreference>(VectorPreferences.SETTINGS_SHOW_URL_PREVIEW_KEY)!!.let { findPreference<SwitchPreference>(VectorPreferences.SETTINGS_SHOW_URL_PREVIEW_KEY)!!.let {
/*
TODO
it.isChecked = session.isURLPreviewEnabled it.isChecked = session.isURLPreviewEnabled
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
@ -100,8 +99,8 @@ class VectorSettingsPreferencesFragment @Inject constructor(
false false
} }
*/
} }
*/
// update keep medias period // update keep medias period
findPreference<VectorPreference>(VectorPreferences.SETTINGS_MEDIA_SAVING_PERIOD_KEY)!!.let { findPreference<VectorPreference>(VectorPreferences.SETTINGS_MEDIA_SAVING_PERIOD_KEY)!!.let {

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:bottom="10dp"
android:left="10dp"
android:right="10dp"
android:top="10dp">
<shape android:shape="oval">
<solid android:color="@color/riotx_accent" />
</shape>
</item>
<item android:drawable="?attr/selectableItemBackground" />
</layer-list>

View file

@ -1,7 +1,21 @@
<vector android:autoMirrored="true" android:height="24dp" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportHeight="24" android:viewportWidth="24" android:width="32dp"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> android:height="32dp"
<path android:fillColor="#ffffff" android:viewportWidth="32"
android:pathData="M21,12C21,16.9706 16.9706,21 12,21C7.0294,21 3,16.9706 3,12C3,7.0294 7.0294,3 12,3C16.9706,3 21,7.0294 21,12ZM8,10C6.8954,10 6,10.8954 6,12C6,13.1046 6.8954,14 8,14H10V16C10,17.1046 10.8954,18 12,18C13.1046,18 14,17.1046 14,16V14H16C17.1046,14 18,13.1046 18,12C18,10.8954 17.1046,10 16,10H14V8C14,6.8954 13.1046,6 12,6C10.8954,6 10,6.8954 10,8V10H8Z" android:viewportHeight="32">
android:strokeColor="#ffffff" android:strokeWidth="2"/> <path
android:pathData="M16,16m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:fillColor="#E3E8F0"/>
<path
android:pathData="M10.0009,16H22.0009"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#61708B"
android:strokeLineCap="round"/>
<path
android:pathData="M16.0009,10V22"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#61708B"
android:strokeLineCap="round"/>
</vector> </vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="25dp"
android:height="24dp"
android:viewportWidth="25"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:fillType="evenOdd"
android:pathData="M17.7929,5.2929C18.1834,4.9024 18.8166,4.9024 19.2071,5.2929C19.5976,5.6834 19.5976,6.3166 19.2071,6.7071L13.9142,12L19.2071,17.2929C19.5976,17.6834 19.5976,18.3166 19.2071,18.7071C18.8166,19.0976 18.1834,19.0976 17.7929,18.7071L12.5,13.4142L7.2071,18.7071C6.8166,19.0976 6.1834,19.0976 5.7929,18.7071C5.4024,18.3166 5.4024,17.6834 5.7929,17.2929L11.0858,12L5.7929,6.7071C5.4024,6.3166 5.4024,5.6834 5.7929,5.2929C6.1834,4.9024 6.8166,4.9024 7.2071,5.2929L12.5,10.5858L17.7929,5.2929Z" />
</vector>

Some files were not shown because too many files have changed in this diff Show more