1
0
mirror of https://github.com/vector-im/riotX-android synced 2025-10-06 00:02:48 +02:00

Compare commits

...

4 Commits

Author SHA1 Message Date
Valere
259f4de37f fix permanent loader 2021-06-01 14:54:35 +02:00
Valere
e26cbf24dd Design update circular progress bar 2021-06-01 14:54:35 +02:00
Valere
ecaf20a20f Better storage settings WIP 2021-06-01 14:54:35 +02:00
Valere
f9a4d95913 Blurhash initial commit 2021-06-01 14:54:35 +02:00
49 changed files with 1452 additions and 136 deletions

View File

@@ -142,6 +142,7 @@ dependencies {
// Image
implementation 'androidx.exifinterface:exifinterface:1.3.2'
implementation 'io.trbl:blurhash:1.0.0'
// Database
implementation 'com.github.Zhuinden:realm-monarchy:0.7.1'

View File

@@ -287,4 +287,6 @@ interface Session :
* Maintenance API, allows to print outs info on DB size to logcat
*/
fun logDbUsageInfo()
fun storageUsageService() : StorageUsageService
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2021 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 org.matrix.android.sdk.api.session
/**
* Some utility to check storage usage.
*/
interface StorageUsageService {
/** Size in bytes */
fun sessionDataBaseSize(): Long
/** Size in bytes */
fun cryptoDataBaseSize(): Long
fun cacheDirectorySize(folderName: String) : Long
}

View File

@@ -55,7 +55,13 @@ data class ImageInfo(
/**
* Information on the encrypted thumbnail file, as specified in End-to-end encryption. Only present if the thumbnail is encrypted.
*/
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null,
/**
* MSC2448: BlurHash is a compact representation of a placeholder for an image (or a frame of video)
*/
@Json(name = "blurhash") val blurHash: String? = null
)
/**

View File

@@ -60,7 +60,12 @@ data class VideoInfo(
/**
* Information on the encrypted thumbnail file, as specified in End-to-end encryption. Only present if the thumbnail is encrypted.
*/
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null,
/**
* MSC2448: BlurHash is a compact representation of a placeholder for an image (or a frame of video)
*/
@Json(name = "blurhash") val blurHash: String? = null
)
/**

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.completeWith
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.content.ContentUrlResolver
import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt
@@ -155,6 +156,11 @@ internal class DefaultFileService @Inject constructor(
}
}
if (!decryptSuccess) {
// the cached file might be corrupted? what should we do here :/
// For now delete, but could be nice to have a hash to check
tryOrNull {
cachedFiles.decryptedFile.delete()
}
throw IllegalStateException("Decryption error")
}
} else {

View File

@@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.federation.FederationService
import org.matrix.android.sdk.api.pushrules.PushRuleService
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.SessionLifecycleObserver
import org.matrix.android.sdk.api.session.StorageUsageService
import org.matrix.android.sdk.api.session.account.AccountService
import org.matrix.android.sdk.api.session.accountdata.AccountDataService
import org.matrix.android.sdk.api.session.cache.CacheService
@@ -128,6 +129,7 @@ internal class DefaultSession @Inject constructor(
private val callSignalingService: Lazy<CallSignalingService>,
private val spaceService: Lazy<SpaceService>,
private val openIdService: Lazy<OpenIdService>,
private val storageUsageService: Lazy<StorageUsageService>,
@UnauthenticatedWithCertificate
private val unauthenticatedWithCertificateOkHttpClient: Lazy<OkHttpClient>
) : Session,
@@ -327,4 +329,8 @@ internal class DefaultSession @Inject constructor(
override fun logDbUsageInfo() {
RealmDebugTools(realmConfiguration).logInfo("Session")
}
override fun storageUsageService(): StorageUsageService {
return storageUsageService.get()
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2021 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 org.matrix.android.sdk.internal.session
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.StorageUsageService
import org.matrix.android.sdk.internal.di.CacheDirectory
import org.matrix.android.sdk.internal.di.SessionFilesDirectory
import java.io.File
import javax.inject.Inject
class DefaultStorageUsageService @Inject constructor(
@SessionFilesDirectory val directory: File,
@CacheDirectory val cacheFile: File
) : StorageUsageService {
// "disk_store.realm"
// "crypto_store.realm"
override fun sessionDataBaseSize(): Long {
return tryOrNull { File(directory, "disk_store.realm").length() } ?: 0
}
override fun cryptoDataBaseSize(): Long {
return tryOrNull { File(directory, "crypto_store.realm").length() } ?: 0
}
override fun cacheDirectorySize(folderName: String): Long {
return tryOrNull {
File(cacheFile, folderName)
.walkTopDown()
.onEnter {
true
}
.sumOf { it.length() }
} ?: 0L
}
}

View File

@@ -34,6 +34,7 @@ import org.matrix.android.sdk.api.auth.data.sessionId
import org.matrix.android.sdk.api.crypto.MXCryptoConfig
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.SessionLifecycleObserver
import org.matrix.android.sdk.api.session.StorageUsageService
import org.matrix.android.sdk.api.session.accountdata.AccountDataService
import org.matrix.android.sdk.api.session.events.EventService
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
@@ -386,4 +387,7 @@ internal abstract class SessionModule {
@Binds
abstract fun bindEventSenderProcessor(processor: EventSenderProcessorCoroutine): EventSenderProcessor
@Binds
abstract fun bindStorageUsageService(processor: DefaultStorageUsageService): StorageUsageService
}

View File

@@ -17,11 +17,13 @@
package org.matrix.android.sdk.internal.session.content
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import androidx.core.net.toUri
import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass
import io.trbl.blurhash.BlurHash
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.listeners.ProgressListener
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
@@ -56,6 +58,7 @@ import javax.inject.Inject
private data class NewAttachmentAttributes(
val newWidth: Int? = null,
val newHeight: Int? = null,
val blurHash: String? = null,
val newFileSize: Long
)
@@ -154,6 +157,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
var newAttachmentAttributes = NewAttachmentAttributes(
params.attachment.width?.toInt(),
params.attachment.height?.toInt(),
null,
params.attachment.size
)
@@ -167,11 +171,25 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
.also { compressedFile ->
// Get new Bitmap size
compressedFile.inputStream().use {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(it, null, options)
val options = BitmapFactory.Options() // .apply { inJustDecodeBounds = true }
val bitmap = BitmapFactory.decodeStream(it, null, options)
val blurHash = if (bitmap != null) {
tryOrNull {
val width = bitmap.width
val height = bitmap.height
val pixels = IntArray(width * height)
if (bitmap.config != Bitmap.Config.ARGB_8888) {
bitmap.copy(Bitmap.Config.ARGB_8888, true)
} else {
bitmap
}.getPixels(pixels, 0, width, 0, 0, width, height)
BlurHash.encode(pixels, width, height, 4, 4)
}
} else null
newAttachmentAttributes = NewAttachmentAttributes(
newWidth = options.outWidth,
newHeight = options.outHeight,
blurHash = blurHash,
newFileSize = compressedFile.length()
)
}
@@ -410,7 +428,8 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
info = info?.copy(
width = newAttachmentAttributes?.newWidth ?: info.width,
height = newAttachmentAttributes?.newHeight ?: info.height,
size = newAttachmentAttributes?.newFileSize ?: info.size
size = newAttachmentAttributes?.newFileSize ?: info.size,
blurHash = newAttachmentAttributes?.blurHash
)
)
}

View File

@@ -169,7 +169,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
}
fun getAllFailedEventsToResend(roomId: String): List<TimelineEvent> {
return getAllEventsWithStates(roomId, SendState.HAS_FAILED_STATES)
return getAllEventsWithStates(roomId, listOf(SendState.UNDELIVERED, SendState.FAILED_UNKNOWN_DEVICES, SendState.SENT))
}
fun getAllEventsWithStates(roomId: String, states: List<SendState>): List<TimelineEvent> {

View File

@@ -410,6 +410,7 @@ dependencies {
implementation "com.github.piasy:GlideImageLoader:$big_image_viewer_version"
implementation "com.github.piasy:ProgressPieIndicator:$big_image_viewer_version"
implementation "com.github.piasy:GlideImageViewFactory:$big_image_viewer_version"
implementation 'xyz.belvi.blurHash:blurHash:1.0.4'
// implementation 'com.github.MikeOrtiz:TouchImageView:3.0.2'
implementation 'com.github.chrisbanes:PhotoView:2.3.0'

View File

@@ -122,6 +122,7 @@ import im.vector.app.features.settings.VectorSettingsPreferencesFragment
import im.vector.app.features.settings.VectorSettingsSecurityPrivacyFragment
import im.vector.app.features.settings.account.deactivation.DeactivateAccountFragment
import im.vector.app.features.settings.crosssigning.CrossSigningSettingsFragment
import im.vector.app.features.settings.data.DataAndStorageFragment
import im.vector.app.features.settings.devices.VectorSettingsDevicesFragment
import im.vector.app.features.settings.devtools.AccountDataFragment
import im.vector.app.features.settings.devtools.GossipingEventsPaperTrailFragment
@@ -804,4 +805,9 @@ interface FragmentModule {
@IntoMap
@FragmentKey(SpaceManageRoomsFragment::class)
fun bindSpaceManageRoomsFragment(fragment: SpaceManageRoomsFragment): Fragment
@Binds
@IntoMap
@FragmentKey(DataAndStorageFragment::class)
fun bindDataAndStorageFragment(fragment: DataAndStorageFragment): Fragment
}

View File

@@ -21,6 +21,7 @@ import androidx.recyclerview.widget.RecyclerView
import dagger.Module
import dagger.Provides
import im.vector.app.core.glide.GlideApp
import xyz.belvi.blurhash.BlurHash
@Module
object ScreenModule {
@@ -33,4 +34,9 @@ object ScreenModule {
@JvmStatic
@ScreenScope
fun providesSharedViewPool() = RecyclerView.RecycledViewPool()
@Provides
@JvmStatic
@ScreenScope
fun providesBlurHash(context: AppCompatActivity) = BlurHash(context, 20, 1f)
}

View File

@@ -20,6 +20,7 @@ import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import org.matrix.android.sdk.api.extensions.orFalse
import timber.log.Timber
import java.io.InputStream
import javax.inject.Inject
@@ -29,7 +30,9 @@ class LocalFilesHelper @Inject constructor(private val context: Context) {
?.let { Uri.parse(it) }
?.let { DocumentFile.fromSingleUri(context, it) }
?.exists()
.orFalse()
.orFalse().also {
Timber.v("## Load data: is Local file $fileUri: $it")
}
}
fun openInputStream(fileUri: String?): InputStream? {

View File

@@ -0,0 +1,141 @@
/*
* Copyright (c) 2021 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.core.glide
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.signature.ObjectKey
import im.vector.app.R
import im.vector.app.core.extensions.vectorComponent
import im.vector.app.features.session.coroutineScope
import im.vector.app.features.themes.ThemeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.extensions.tryOrNull
import xyz.belvi.blurhash.BlurHashDecoder
data class BlurHashData(
val blurHash: String? = null
)
class BlurHashModelLoaderFactory(private val context: Context) : ModelLoaderFactory<BlurHashData, Drawable> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<BlurHashData, Drawable> {
return BlurHashModelLoader(context)
}
override fun teardown() {
// nop??
}
}
class BlurHashModelLoader(val context: Context)
: ModelLoader<BlurHashData, Drawable> {
private val activeSessionHolder = context.vectorComponent().activeSessionHolder()
override fun buildLoadData(model: BlurHashData, width: Int, height: Int, options: Options): ModelLoader.LoadData<Drawable> {
return ModelLoader.LoadData(ObjectKey(model),
BlurhashDataFetcher(context,
activeSessionHolder.getSafeActiveSession()?.coroutineScope ?: GlobalScope,
model.blurHash,
width,
height
))
}
override fun handles(model: BlurHashData): Boolean {
return true
}
}
class BlurhashDataFetcher(private val context: Context,
private val scope: CoroutineScope,
private val hash: String?,
private val width: Int,
private val height: Int)
: DataFetcher<Drawable> {
var job: Job? = null
val defaultHash = "LEHV6nWB2yk8pyoJadR*.7kCMdnj"
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Drawable>) {
job = scope.launch {
withContext(Dispatchers.Default) {
val result = runCatching {
(
tryOrNull { BlurHashDecoder.decode(hash ?: defaultHash, width, height) }
?: createImage(width, height, ThemeUtils.getColor(context, R.attr.riotx_reaction_background_off))
)
?.let {
BitmapDrawable(
context.resources,
it
)
}
}
withContext(Dispatchers.Main) {
result.fold(
{ callback.onDataReady(it) },
{ callback.onLoadFailed(Exception(it)) }
)
}
}
}
}
override fun getDataSource(): DataSource {
// ?
return DataSource.LOCAL
}
override fun cleanup() {
// nop?
}
override fun cancel() {
job?.cancel()
}
override fun getDataClass(): Class<Drawable> {
return Drawable::class.java
}
fun createImage(width: Int, height: Int, color: Int): Bitmap? {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint()
paint.setColor(color)
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
return bitmap
}
}

View File

@@ -17,6 +17,7 @@
package im.vector.app.core.glide
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.Log
import com.bumptech.glide.Glide
@@ -35,6 +36,12 @@ class MyAppGlideModule : AppGlideModule() {
}
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(
BlurHashData::class.java,
Drawable::class.java,
BlurHashModelLoaderFactory(context)
)
registry.append(
ImageContentRenderer.Data::class.java,
InputStream::class.java,

View File

@@ -32,11 +32,15 @@ import im.vector.app.features.session.coroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.matrix.android.sdk.api.extensions.tryOrNull
import timber.log.Timber
import java.io.IOException
import java.io.InputStream
/**
* Will be used to download encrypted attachment.
* Clear attachment will use regular http URL fetcher
*/
class VectorGlideModelLoaderFactory(private val context: Context) : ModelLoaderFactory<ImageContentRenderer.Data, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<ImageContentRenderer.Data, InputStream> {
@@ -69,7 +73,7 @@ class VectorGlideDataFetcher(context: Context,
private val localFilesHelper = LocalFilesHelper(context)
private val activeSessionHolder = context.vectorComponent().activeSessionHolder()
private val client = activeSessionHolder.getSafeActiveSession()?.getOkHttpClient() ?: OkHttpClient()
// private val client = activeSessionHolder.getSafeActiveSession()?.getOkHttpClient() ?: OkHttpClient()
override fun getDataClass(): Class<InputStream> {
return InputStream::class.java
@@ -102,14 +106,14 @@ class VectorGlideDataFetcher(context: Context,
}
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) {
Timber.v("Load data: $data")
Timber.v("## Load data: ${data.url} is Local ${localFilesHelper.isLocalFile(data.url)}")
if (localFilesHelper.isLocalFile(data.url)) {
localFilesHelper.openInputStream(data.url)?.use {
Timber.v("## Load data: Got a local input stream!!")
callback.onDataReady(it)
}
return
}
// val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
val fileService = activeSessionHolder.getSafeActiveSession()?.fileService() ?: return Unit.also {
callback.onLoadFailed(IllegalArgumentException("No File service"))
@@ -125,30 +129,16 @@ class VectorGlideDataFetcher(context: Context,
}
withContext(Dispatchers.Main) {
result.fold(
{ callback.onDataReady(it.inputStream()) },
{
callback.onDataReady(
// from crash report (clearing eg clear media cache while download completing?)
tryOrNull {
it.inputStream()
})
},
{ callback.onLoadFailed(it as? Exception ?: IOException(it.localizedMessage)) }
)
}
}
// val url = contentUrlResolver.resolveFullSize(data.url)
// ?: return
//
// val request = Request.Builder()
// .url(url)
// .build()
//
// val response = client.newCall(request).execute()
// val inputStream = response.body?.byteStream()
// Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${inputStream?.available()}")
// if (!response.isSuccessful) {
// callback.onLoadFailed(IOException("Unexpected code $response"))
// return
// }
// stream = if (data.elementToDecrypt != null && data.elementToDecrypt.k.isNotBlank()) {
// Matrix.decryptStream(inputStream, data.elementToDecrypt)
// } else {
// inputStream
// }
// callback.onDataReady(stream)
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.core.platform
import android.content.Context
import android.util.AttributeSet
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.core.utils.DebouncedClickListener
import im.vector.app.databinding.MediaViewStateBinding
class TimelineMediaStateView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0)
: FrameLayout(context, attrs, defStyle) {
sealed class State {
object NotDownloaded : State()
data class Downloading(val progress: Int, val indeterminate: Boolean) : State()
object PermanentError : State()
object ReadyToPlay : State()
object None : State()
}
var callback: Callback? = null
interface Callback {
fun onButtonClicked()
}
private val views: MediaViewStateBinding
init {
inflate(context, R.layout.media_view_state, this)
views = MediaViewStateBinding.bind(this)
layoutParams = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
// views.buttonStateRetry.setOnClickListener {
// callback?.onRetryClicked()
// }
// Read attributes
// context.theme.obtainStyledAttributes(
// attrs,
// R.styleable.ButtonStateView,
// 0, 0)
// .apply {
// try {
// if (getBoolean(R.styleable.ButtonStateView_bsv_use_flat_button, true)) {
// button = views.buttonStateButtonFlat
// views.buttonStateButtonBig.isVisible = false
// } else {
// button = views.buttonStateButtonBig
// views.buttonStateButtonFlat.isVisible = false
// }
//
// button.text = getString(R.styleable.ButtonStateView_bsv_button_text)
// views.buttonStateLoaded.setImageDrawable(getDrawable(R.styleable.ButtonStateView_bsv_loaded_image_src))
// } finally {
// recycle()
// }
// }
if (isInEditMode) {
render(State.Downloading(20, false))
}
setOnClickListener(DebouncedClickListener({
callback?.onButtonClicked()
}))
}
fun render(newState: State) {
isVisible = newState != State.None
views.mediaStateNotDownloaded.isVisible = newState == State.NotDownloaded
views.circularProgress.isVisible = newState is State.Downloading
(newState as? State.Downloading)?.let {
views.mediaProgressView.progressBar.progress = it.progress
views.mediaProgressView.progressBar.isIndeterminate = it.indeterminate
}
views.mediaStateError.isVisible = newState == State.PermanentError
views.mediaStatePlay.isVisible = newState == State.ReadyToPlay
}
}

View File

@@ -36,3 +36,7 @@ class DebouncedClickListener(val original: View.OnClickListener, private val min
}
}
}
fun View.setDebouncedClickListener(clickListener: View.OnClickListener?) {
setOnClickListener(clickListener?.let { DebouncedClickListener(it) })
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2021 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.core.utils
import android.content.Context
import javax.inject.Inject
class SizeByteFormatter @Inject constructor(private val context: Context) {
fun formatFileSize(sizeBytes: Long, useShortFormat: Boolean = false): String {
return TextUtils.formatFileSize(context, sizeBytes, useShortFormat)
}
}

View File

@@ -64,6 +64,7 @@ import im.vector.app.features.html.SpanUtils
import im.vector.app.features.html.VectorHtmlCompressor
import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.settings.VectorPreferences
import me.gujun.android.span.span
import org.commonmark.node.Document
import org.matrix.android.sdk.api.session.Session
@@ -111,6 +112,7 @@ class MessageItemFactory @Inject constructor(
private val avatarSizeProvider: AvatarSizeProvider,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val spanUtils: SpanUtils,
private val vectorPreferences: VectorPreferences,
private val session: Session) {
// TODO inject this properly?
@@ -120,6 +122,24 @@ class MessageItemFactory @Inject constructor(
pillsPostProcessorFactory.create(roomId)
}
private val shouldAutoPlayGif: Boolean by lazy {
val autoPref = vectorPreferences.getGifAutoPlayPreference()
session.getRoomSummary(roomId)?.let {
if (it.isDirect) {
autoPref == VectorPreferences.GifAutoPlayPreference.ALWAYS
|| autoPref == VectorPreferences.GifAutoPlayPreference.DM
} else {
if (it.isPublic) {
autoPref == VectorPreferences.GifAutoPlayPreference.ALWAYS
} else {
autoPref == VectorPreferences.GifAutoPlayPreference.ALWAYS
|| autoPref == VectorPreferences.GifAutoPlayPreference.DM_PRIVATE
}
}
}
?: false
}
fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? {
val event = params.event
val highlight = params.isHighlighted
@@ -308,13 +328,24 @@ class MessageItemFactory @Inject constructor(
maxHeight = maxHeight,
width = messageContent.info?.width,
maxWidth = maxWidth,
allowNonMxcUrls = informationData.sendState.isSending()
allowNonMxcUrls = informationData.sendState.isSending(),
blurHash = messageContent.info?.blurHash
)
return MessageImageVideoItem_()
.attributes(attributes)
.leftGuideline(avatarSizeProvider.leftGuideline)
.imageContentRenderer(imageContentRenderer)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
.dimensionConverter(dimensionConverter)
.autoPlayGifs(shouldAutoPlayGif)
.izLocalFile(localFilesHelper.isLocalFile(messageContent.getFileUrl()))
.izDownloaded(session.fileService().isFileInCache(
messageContent.getFileUrl(),
messageContent.getFileName(),
messageContent.mimeType,
messageContent.encryptedFileInfo?.toElementToDecrypt())
)
.playable(messageContent.info?.mimeType == MimeTypes.Gif)
.highlighted(highlight)
.mediaData(data)
@@ -346,7 +377,8 @@ class MessageItemFactory @Inject constructor(
maxHeight = maxHeight,
width = messageContent.videoInfo?.width,
maxWidth = maxWidth,
allowNonMxcUrls = informationData.sendState.isSending()
allowNonMxcUrls = informationData.sendState.isSending(),
blurHash = messageContent.videoInfo?.blurHash
)
val videoData = VideoContentRenderer.Data(
@@ -363,6 +395,15 @@ class MessageItemFactory @Inject constructor(
.attributes(attributes)
.imageContentRenderer(imageContentRenderer)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
.dimensionConverter(dimensionConverter)
.izLocalFile(localFilesHelper.isLocalFile(messageContent.getFileUrl()))
.izDownloaded(session.fileService().isFileInCache(
messageContent.getFileUrl(),
messageContent.getFileName(),
messageContent.mimeType,
messageContent.encryptedFileInfo?.toElementToDecrypt())
)
.playable(true)
.highlighted(highlight)
.mediaData(thumbnailData)

View File

@@ -23,8 +23,10 @@ import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.ScreenScope
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.platform.TimelineMediaStateView
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem
import im.vector.app.features.home.room.detail.timeline.item.MessageImageVideoItem
import org.matrix.android.sdk.api.session.file.ContentDownloadStateTracker
import javax.inject.Inject
@@ -33,13 +35,24 @@ class ContentDownloadStateTrackerBinder @Inject constructor(private val activeSe
private val messageColorProvider: MessageColorProvider,
private val errorFormatter: ErrorFormatter) {
private val updateListeners = mutableMapOf<String, ContentDownloadUpdater>()
private val updateListeners = mutableMapOf<String, DownloadUpdater>()
fun bind(mxcUrl: String,
holder: MessageFileItem.Holder) {
activeSessionHolder.getSafeActiveSession()?.also { session ->
val downloadStateTracker = session.contentDownloadProgressTracker()
val updateListener = ContentDownloadUpdater(holder, messageColorProvider, errorFormatter)
val updateListener = FileContentDownloadUpdater(holder)
updateListeners[mxcUrl] = updateListener
downloadStateTracker.track(mxcUrl, updateListener)
}
}
fun bind(mxcUrl: String,
playable: Boolean,
holder: MessageImageVideoItem.Holder) {
activeSessionHolder.getSafeActiveSession()?.also { session ->
val downloadStateTracker = session.contentDownloadProgressTracker()
val updateListener = ImageContentDownloadUpdater(holder, playable)
updateListeners[mxcUrl] = updateListener
downloadStateTracker.track(mxcUrl, updateListener)
}
@@ -52,6 +65,7 @@ class ContentDownloadStateTrackerBinder @Inject constructor(private val activeSe
it.stop()
downloadStateTracker.unTrack(mxcUrl, it)
}
updateListeners.remove(mxcUrl)
}
}
@@ -62,9 +76,11 @@ class ContentDownloadStateTrackerBinder @Inject constructor(private val activeSe
}
}
private class ContentDownloadUpdater(private val holder: MessageFileItem.Holder,
private val messageColorProvider: MessageColorProvider,
private val errorFormatter: ErrorFormatter) : ContentDownloadStateTracker.UpdateListener {
private interface DownloadUpdater: ContentDownloadStateTracker.UpdateListener {
fun stop()
}
private class FileContentDownloadUpdater(private val holder: MessageFileItem.Holder) : DownloadUpdater {
override fun onDownloadStateUpdate(state: ContentDownloadStateTracker.State) {
when (state) {
@@ -83,7 +99,7 @@ private class ContentDownloadUpdater(private val holder: MessageFileItem.Holder,
}
}
fun stop() {
override fun stop() {
animatedDrawable?.unregisterAnimationCallback(animationLoopCallback)
animatedDrawable?.stop()
animatedDrawable = null
@@ -128,3 +144,62 @@ private class ContentDownloadUpdater(private val holder: MessageFileItem.Holder,
holder.fileImageView.setImageResource(R.drawable.ic_paperclip)
}
}
private class ImageContentDownloadUpdater(private val holder: MessageImageVideoItem.Holder, val playable: Boolean) : DownloadUpdater {
override fun onDownloadStateUpdate(state: ContentDownloadStateTracker.State) {
when (state) {
ContentDownloadStateTracker.State.Idle -> handleIdle()
is ContentDownloadStateTracker.State.Downloading -> handleProgress(state)
ContentDownloadStateTracker.State.Decrypting -> handleDecrypting()
ContentDownloadStateTracker.State.Success -> handleSuccess()
is ContentDownloadStateTracker.State.Failure -> handleFailure()
}
}
// private var animatedDrawable: AnimatedVectorDrawableCompat? = null
// private var animationLoopCallback = object : Animatable2Compat.AnimationCallback() {
// override fun onAnimationEnd(drawable: Drawable?) {
// animatedDrawable?.start()
// }
// }
override fun stop() {
// animatedDrawable?.unregisterAnimationCallback(animationLoopCallback)
// animatedDrawable?.stop()
// animatedDrawable = null
}
private fun handleIdle() {
holder.mediaStateView.render(TimelineMediaStateView.State.Downloading(0, true))
}
private fun handleDecrypting() {
holder.mediaStateView.render(TimelineMediaStateView.State.Downloading(0, true))
}
private fun handleProgress(state: ContentDownloadStateTracker.State.Downloading) {
doHandleProgress(state.current, state.total)
}
private fun doHandleProgress(current: Long, total: Long) {
val percent = 100L * (current.toFloat() / total.toFloat())
holder.mediaStateView.render(TimelineMediaStateView.State.Downloading(percent.toInt(), false))
// if (animatedDrawable == null) {
// animatedDrawable = AnimatedVectorDrawableCompat.create(holder.view.context, R.drawable.ic_download_anim)
// holder.fileImageView.setImageDrawable(animatedDrawable)
// animatedDrawable?.start()
// animatedDrawable?.registerAnimationCallback(animationLoopCallback)
// }
}
private fun handleFailure() {
stop()
holder.mediaStateView.render(TimelineMediaStateView.State.PermanentError)
}
private fun handleSuccess() {
stop()
holder.mediaStateView.render(if (playable) TimelineMediaStateView.State.ReadyToPlay else TimelineMediaStateView.State.None)
}
}

View File

@@ -27,6 +27,7 @@ import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.ScreenScope
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.TimelineMediaStateView
import im.vector.app.core.utils.TextUtils
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import org.matrix.android.sdk.api.session.content.ContentUploadStateTracker
@@ -42,10 +43,11 @@ class ContentUploadStateTrackerBinder @Inject constructor(private val activeSess
fun bind(eventId: String,
isLocalFile: Boolean,
progressLayout: ViewGroup) {
progressLayout: ViewGroup,
mediaStateView: TimelineMediaStateView?) {
activeSessionHolder.getSafeActiveSession()?.also { session ->
val uploadStateTracker = session.contentUploadProgressTracker()
val updateListener = ContentMediaProgressUpdater(progressLayout, isLocalFile, messageColorProvider, errorFormatter)
val updateListener = ContentMediaProgressUpdater(progressLayout, mediaStateView, isLocalFile, messageColorProvider, errorFormatter)
updateListeners[eventId] = updateListener
uploadStateTracker.track(eventId, updateListener)
}
@@ -68,6 +70,7 @@ class ContentUploadStateTrackerBinder @Inject constructor(private val activeSess
}
private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
private val mediaStateView: TimelineMediaStateView?,
private val isLocalFile: Boolean,
private val messageColorProvider: MessageColorProvider,
private val errorFormatter: ErrorFormatter) : ContentUploadStateTracker.UpdateListener {
@@ -91,14 +94,20 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
private fun handleIdle() {
if (isLocalFile) {
if (mediaStateView != null) {
progressBar.isVisible = false
mediaStateView.render(TimelineMediaStateView.State.Downloading(0, true))
} else {
progressBar.isVisible = true
progressBar.isIndeterminate = true
progressBar.progress = 0
}
progressLayout.isVisible = true
progressBar.isVisible = true
progressBar.isIndeterminate = true
progressBar.progress = 0
progressTextView.text = progressLayout.context.getString(R.string.send_file_step_idle)
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNSENT))
} else {
progressLayout.isVisible = false
mediaStateView?.render(TimelineMediaStateView.State.Downloading(0, true))
}
}
@@ -120,8 +129,13 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
private fun handleCompressingImage() {
progressLayout.visibility = View.VISIBLE
progressBar.isVisible = true
progressBar.isIndeterminate = true
if (mediaStateView != null) {
progressBar.isVisible = false
mediaStateView.render(TimelineMediaStateView.State.Downloading(10, true))
} else {
progressBar.isVisible = true
progressBar.isIndeterminate = true
}
progressTextView.isVisible = true
progressTextView.text = progressLayout.context.getString(R.string.send_file_step_compressing_image)
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING))
@@ -131,9 +145,14 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
@SuppressLint("StringFormatMatches")
private fun handleCompressingVideo(state: ContentUploadStateTracker.State.CompressingVideo) {
progressLayout.visibility = View.VISIBLE
progressBar.isVisible = true
progressBar.isIndeterminate = false
progressBar.progress = state.percent.toInt()
if (mediaStateView != null) {
progressBar.isVisible = false
mediaStateView.render(TimelineMediaStateView.State.Downloading(state.percent.toInt(), false))
} else {
progressBar.isVisible = true
progressBar.isIndeterminate = false
progressBar.progress = state.percent.toInt()
}
progressTextView.isVisible = true
// False positive is here...
progressTextView.text = progressLayout.context.getString(R.string.send_file_step_compressing_video, state.percent.toInt())
@@ -141,21 +160,33 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
}
private fun doHandleEncrypting(resId: Int, current: Long, total: Long) {
progressLayout.visibility = View.VISIBLE
val percent = if (total > 0) (100L * (current.toFloat() / total.toFloat())) else 0f
progressBar.isIndeterminate = false
progressBar.progress = percent.toInt()
progressLayout.visibility = View.VISIBLE
if (mediaStateView != null) {
progressBar.isVisible = false
mediaStateView.render(TimelineMediaStateView.State.Downloading(percent.toInt(), false))
} else {
progressBar.isVisible = true
progressBar.isIndeterminate = false
progressBar.progress = percent.toInt()
}
progressTextView.isVisible = true
progressTextView.text = progressLayout.context.getString(resId)
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING))
}
private fun doHandleProgress(resId: Int, current: Long, total: Long) {
progressLayout.visibility = View.VISIBLE
val percent = 100L * (current.toFloat() / total.toFloat())
progressBar.isVisible = true
progressBar.isIndeterminate = false
progressBar.progress = percent.toInt()
progressLayout.visibility = View.VISIBLE
if (mediaStateView != null) {
progressBar.isVisible = false
mediaStateView.render(TimelineMediaStateView.State.Downloading(percent.toInt(), false))
} else {
progressBar.isVisible = true
progressBar.isIndeterminate = false
progressBar.progress = percent.toInt()
}
progressTextView.isVisible = true
progressTextView.text = progressLayout.context.getString(resId,
TextUtils.formatFileSize(progressLayout.context, current, true),
@@ -164,6 +195,7 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
}
private fun handleFailure(/*state: ContentUploadStateTracker.State.Failure*/) {
mediaStateView?.render(TimelineMediaStateView.State.PermanentError)
progressLayout.visibility = View.VISIBLE
progressBar.isVisible = false
// Do not show the message it's too technical for users, and unfortunate when upload is cancelled

View File

@@ -41,7 +41,8 @@ fun TimelineEvent.buildImageContentRendererData(maxHeight: Int): ImageContentRen
maxHeight = maxHeight,
width = messageImageContent.info?.width,
maxWidth = maxHeight * 2,
allowNonMxcUrls = false
allowNonMxcUrls = false,
blurHash = messageImageContent.info?.blurHash
)
}
root.isVideoMessage() -> root.getClearContent().toModel<MessageVideoContent>()
@@ -57,7 +58,8 @@ fun TimelineEvent.buildImageContentRendererData(maxHeight: Int): ImageContentRen
maxHeight = maxHeight,
width = videoInfo?.thumbnailInfo?.width,
maxWidth = maxHeight * 2,
allowNonMxcUrls = false
allowNonMxcUrls = false,
blurHash = videoInfo?.blurHash
)
}
else -> null

View File

@@ -61,7 +61,7 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
super.bind(holder)
renderSendState(holder.fileLayout, holder.filenameView)
if (!attributes.informationData.sendState.hasFailed()) {
contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout)
contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout, null)
} else {
holder.fileImageView.setImageResource(R.drawable.ic_cross)
holder.progressLayout.isVisible = false

View File

@@ -26,6 +26,9 @@ import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.files.LocalFilesHelper
import im.vector.app.core.glide.GlideApp
import im.vector.app.core.platform.TimelineMediaStateView
import im.vector.app.core.utils.setDebouncedClickListener
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.media.ImageContentRenderer
@@ -50,30 +53,90 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
@EpoxyAttribute
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
@EpoxyAttribute
lateinit var contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder
@EpoxyAttribute
var izLocalFile = false
@EpoxyAttribute
var izDownloaded = false
@EpoxyAttribute
var autoPlayGifs = false
override fun bind(holder: Holder) {
super.bind(holder)
imageContentRenderer.render(mediaData, mode, holder.imageView)
if (!attributes.informationData.sendState.hasFailed()) {
contentUploadStateTrackerBinder.bind(
attributes.informationData.eventId,
LocalFilesHelper(holder.view.context).isLocalFile(mediaData.url),
holder.progressLayout
holder.progressLayout,
holder.mediaStateView
)
} else {
holder.progressLayout.isVisible = false
}
holder.imageView.setOnClickListener(clickListener)
if (!izDownloaded && !izLocalFile) {
contentDownloadStateTrackerBinder.bind(
mediaData.url ?: "",
playable && !autoPlayGifs,
holder)
} else {
holder.mediaStateView.render(if (playable && !autoPlayGifs) TimelineMediaStateView.State.ReadyToPlay else TimelineMediaStateView.State.None)
}
holder.mediaStateView.setTag(R.id.messageMediaStateView, mediaData.url)
imageContentRenderer.render(mediaData, mode, holder.imageView, autoPlayGifs, rendererCallbacks = object: ImageContentRenderer.ContentRendererCallbacks {
override fun onThumbnailModeFinish(success: Boolean) {
// if a server thumbnail was used the download tracker won't be called
mediaData.url?.let { mxcUrl ->
if (mxcUrl == holder.mediaStateView.getTag(R.id.messageMediaStateView)) {
contentDownloadStateTrackerBinder.unbind(mxcUrl)
// mmm annoying but have to post if not the previous contentDownloadStateTrackerBinder.bind call we render
// an IDLE state (i.e a loading wheel...)
holder.mediaStateView.post {
holder.mediaStateView.render(if (success) TimelineMediaStateView.State.None else TimelineMediaStateView.State.PermanentError)
}
}
}
}
override fun onLoadModeFinish(success: Boolean) {
// if a server thumbnail was used the download tracker won't be called
mediaData.url?.let { mxcUrl ->
if (mxcUrl == holder.mediaStateView.getTag(R.id.messageMediaStateView)) {
contentDownloadStateTrackerBinder.unbind(mxcUrl)
// mmm annoying but have to post if not the previous contentDownloadStateTrackerBinder.bind call we render
// an IDLE state (i.e a loading wheel...)
holder.mediaStateView.post {
holder.mediaStateView.render(if (success) TimelineMediaStateView.State.None else TimelineMediaStateView.State.PermanentError)
}
}
}
}
})
holder.mediaStateView.callback = object : TimelineMediaStateView.Callback {
override fun onButtonClicked() {
// for now delegate to regular click
clickListener?.onClick(holder.imageView)
}
}
holder.imageView.setDebouncedClickListener(clickListener)
holder.imageView.setOnLongClickListener(attributes.itemLongClickListener)
ViewCompat.setTransitionName(holder.imageView, "imagePreview_${id()}")
holder.mediaContentView.setOnClickListener(attributes.itemClickListener)
holder.mediaContentView.setDebouncedClickListener(attributes.itemClickListener)
holder.mediaContentView.setOnLongClickListener(attributes.itemLongClickListener)
holder.playContentView.visibility = if (playable) View.VISIBLE else View.GONE
// holder.playContentView.visibility = if (playable) View.VISIBLE else View.GONE
}
override fun unbind(holder: Holder) {
holder.mediaStateView.setTag(R.id.messageMediaStateView, null)
GlideApp.with(holder.view.context.applicationContext).clear(holder.imageView)
imageContentRenderer.clear(holder.imageView)
contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
contentDownloadStateTrackerBinder.unbind(mediaData.url ?: "")
holder.imageView.setOnClickListener(null)
holder.imageView.setOnLongClickListener(null)
super.unbind(holder)
@@ -83,8 +146,10 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
class Holder : AbsMessageItem.Holder(STUB_ID) {
val progressLayout by bind<ViewGroup>(R.id.messageMediaUploadProgressLayout)
val mediaStateView by bind<TimelineMediaStateView>(R.id.messageMediaStateView)
val imageView by bind<ImageView>(R.id.messageThumbnailView)
val playContentView by bind<ImageView>(R.id.messageMediaPlayView)
// val playContentView by bind<ImageView>(R.id.messageMediaPlayView)
val mediaContentView by bind<ViewGroup>(R.id.messageContentMedia)
}

View File

@@ -16,6 +16,7 @@
package im.vector.app.features.media
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Parcelable
@@ -28,22 +29,27 @@ import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.CustomViewTarget
import com.bumptech.glide.request.target.ImageViewTarget
import com.bumptech.glide.request.target.Target
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.ORIENTATION_USE_EXIF
import com.github.piasy.biv.view.BigImageView
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.files.LocalFilesHelper
import im.vector.app.core.glide.BlurHashData
import im.vector.app.core.glide.GlideApp
import im.vector.app.core.glide.GlideRequest
import im.vector.app.core.glide.GlideRequests
import im.vector.app.core.ui.model.Size
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.themes.ThemeUtils
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.content.ContentUrlResolver
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt
import timber.log.Timber
import xyz.belvi.blurhash.BlurHash
import java.io.File
import javax.inject.Inject
import kotlin.math.min
@@ -61,7 +67,9 @@ interface AttachmentData : Parcelable {
class ImageContentRenderer @Inject constructor(private val localFilesHelper: LocalFilesHelper,
private val activeSessionHolder: ActiveSessionHolder,
private val dimensionConverter: DimensionConverter) {
private val dimensionConverter: DimensionConverter,
// private val contentDownloadStateTracker: ContentDownloadStateTracker,
private val blurHash: BlurHash) {
@Parcelize
data class Data(
@@ -74,8 +82,10 @@ class ImageContentRenderer @Inject constructor(private val localFilesHelper: Loc
val maxHeight: Int,
val width: Int?,
val maxWidth: Int,
val blurHash: String?,
// If true will load non mxc url, be careful to set it only for images sent by you
override val allowNonMxcUrls: Boolean = false
override val allowNonMxcUrls: Boolean = false,
val autoDownload: Boolean = false
) : AttachmentData
enum class Mode {
@@ -109,8 +119,20 @@ class ImageContentRenderer @Inject constructor(private val localFilesHelper: Loc
.into(imageView)
}
fun render(data: Data, mode: Mode, imageView: ImageView) {
interface ContentRendererCallbacks {
fun onThumbnailModeFinish(success: Boolean)
fun onLoadModeFinish(success: Boolean)
}
/**
* In timeline
* All encrypted media will be downloaded by the SDK's FileService, so caller could follow progress using download tracker,
* but for clear media a server thumbnail will be requested and in this case it will be invisible to download tracker that's why there is the
* `mxcThumbnailCallback` callback. Caller can use it to know when media is loaded.
*/
fun render(data: Data, mode: Mode, imageView: ImageView, animate: Boolean = false, rendererCallbacks: ContentRendererCallbacks? = null) {
val size = processSize(data, mode)
// This size will be used by glide for bitmap size
imageView.updateLayoutParams {
width = size.width
height = size.height
@@ -118,11 +140,47 @@ class ImageContentRenderer @Inject constructor(private val localFilesHelper: Loc
// a11y
imageView.contentDescription = data.filename
createGlideRequest(data, mode, imageView, size)
.dontAnimate()
createGlideRequest(data, mode, imageView, size, animate, rendererCallbacks)
.apply {
if (!animate) {
dontAnimate()
}
}
// .dontAnimate()
.transform(RoundedCorners(dimensionConverter.dpToPx(8)))
// .thumbnail(0.3f)
.into(imageView)
.placeholder(ColorDrawable(ThemeUtils.getColor(imageView.context, R.attr.riotx_reaction_background_off)))
.apply {
if (data.blurHash != null) {
thumbnail(
GlideApp.with(imageView)
.load(BlurHashData(data.blurHash))
.transform(RoundedCorners(dimensionConverter.dpToPx(8)))
.diskCacheStrategy(DiskCacheStrategy.NONE)
)
}
}
.apply {
// In case of permanent error, the thumbnail might not be loaded and images goes directly to blank state instead of
// loading the blurhash thumbnail.. so ensure that error will use the blur hash
if (data.blurHash != null) {
error(
GlideApp.with(imageView)
.load(BlurHashData(data.blurHash))
.transform(RoundedCorners(dimensionConverter.dpToPx(8)))
.diskCacheStrategy(DiskCacheStrategy.NONE)
).error(ColorDrawable(ThemeUtils.getColor(imageView.context, R.attr.riotx_reaction_background_off)))
} else {
error(ColorDrawable(ThemeUtils.getColor(imageView.context, R.attr.riotx_reaction_background_off)))
}
}
.into(object : ImageViewTarget<Drawable>(imageView) {
override fun setResource(resource: Drawable?) {
resource?.let {
imageView.post { imageView.setImageDrawable(it) }
}
}
})
}
fun clear(imageView: ImageView) {
@@ -150,41 +208,46 @@ class ImageContentRenderer @Inject constructor(private val localFilesHelper: Loc
GlideApp
.with(contextView)
.load(resolvedUrl)
.thumbnail()
}
req.override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
.thumbnail(
GlideApp.with(contextView)
.load(BlurHashData(data.blurHash))
)
.fitCenter()
.into(target)
}
fun renderFitTarget(data: Data, mode: Mode, imageView: ImageView, callback: ((Boolean) -> Unit)? = null) {
val size = processSize(data, mode)
// a11y
imageView.contentDescription = data.filename
createGlideRequest(data, mode, imageView, size)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean): Boolean {
callback?.invoke(false)
return false
}
override fun onResourceReady(resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean): Boolean {
callback?.invoke(true)
return false
}
})
.fitCenter()
.into(imageView)
}
// fun renderFitTarget(data: Data, mode: Mode, imageView: ImageView, callback: ((Boolean) -> Unit)? = null) {
// val size = processSize(data, mode)
//
// // a11y
// imageView.contentDescription = data.filename
//
// createGlideRequest(data, mode, imageView, size)
// .listener(object : RequestListener<Drawable> {
// override fun onLoadFailed(e: GlideException?,
// model: Any?,
// target: Target<Drawable>?,
// isFirstResource: Boolean): Boolean {
// callback?.invoke(false)
// return false
// }
//
// override fun onResourceReady(resource: Drawable?,
// model: Any?,
// target: Target<Drawable>?,
// dataSource: DataSource?,
// isFirstResource: Boolean): Boolean {
// callback?.invoke(true)
// return false
// }
// })
// .fitCenter()
// .into(imageView)
// }
/**
* onlyRetrieveFromCache is true!
@@ -197,13 +260,14 @@ class ImageContentRenderer @Inject constructor(private val localFilesHelper: Loc
// Encrypted image
GlideApp
.with(imageView)
.asDrawable()
.load(data)
.diskCacheStrategy(DiskCacheStrategy.NONE)
} else {
// Clear image
val resolvedUrl = resolveUrl(data)
GlideApp
.with(imageView)
.asDrawable()
.load(resolvedUrl)
}
@@ -230,36 +294,124 @@ class ImageContentRenderer @Inject constructor(private val localFilesHelper: Loc
.into(imageView)
}
private fun createGlideRequest(data: Data, mode: Mode, imageView: ImageView, size: Size): GlideRequest<Drawable> {
return createGlideRequest(data, mode, GlideApp.with(imageView), size)
private fun createGlideRequest(data: Data, mode: Mode, imageView: ImageView, size: Size, autoplay: Boolean = false, rendererCallbacks: ContentRendererCallbacks? = null): GlideRequest<Drawable> {
return createGlideRequest(data, mode, GlideApp.with(imageView), size, autoplay, rendererCallbacks)
}
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), autoplay: Boolean = false, rendererCallbacks: ContentRendererCallbacks? = null): GlideRequest<Drawable> {
return if (data.elementToDecrypt != null) {
// Encrypted image
glideRequests
.apply {
if (!autoplay && data.mimeType == MimeTypes.Gif) {
// if it's a gif and that we don't auto play,
// there is no point of loading all frames, just use this to take first one
asBitmap()
}
}
.load(data)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
rendererCallbacks?.onLoadModeFinish(false)
return false
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
rendererCallbacks?.onLoadModeFinish(true)
return false
}
})
.diskCacheStrategy(DiskCacheStrategy.NONE)
} else {
// Clear image
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
val resolvedUrl = when (mode) {
Mode.FULL_SIZE,
Mode.STICKER -> resolveUrl(data)
Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, size.width, size.height, ContentUrlResolver.ThumbnailMethod.SCALE)
}
// Fallback to base url
?: data.url.takeIf { it?.startsWith("content://") == true }
// Check if it's worth it asking the server for a thumbnail
val shouldQueryThumb = if (mode == Mode.THUMBNAIL) {
(data.width == null || data.height == null
|| size.width * size.height < (data.width * data.height) * 2)
} else false
glideRequests
.load(resolvedUrl)
.apply {
if (mode == Mode.THUMBNAIL) {
error(
glideRequests.load(resolveUrl(data))
)
if (shouldQueryThumb) {
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
glideRequests
.apply {
if (!autoplay && data.mimeType == MimeTypes.Gif) {
// if it's a gif and that we don't auto play,
// there is no point of loading all frames, just use this to take first one
asBitmap()
}
}.load(
contentUrlResolver.resolveThumbnail(data.url, size.width, size.height, ContentUrlResolver.ThumbnailMethod.SCALE)
?: data.url.takeIf { it?.startsWith("content://") == true }
).listener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
rendererCallbacks?.onThumbnailModeFinish(false)
return false
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
rendererCallbacks?.onThumbnailModeFinish(true)
return false
}
})
} else {
glideRequests
.apply {
if (!autoplay && data.mimeType == MimeTypes.Gif) {
// if it's a gif and that we don't auto play,
// there is no point of loading all frames, just use this to take first one
asBitmap()
}
}
}
.load(data)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
rendererCallbacks?.onLoadModeFinish(false)
return false
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
rendererCallbacks?.onLoadModeFinish(true)
return false
}
})
.diskCacheStrategy(DiskCacheStrategy.NONE)
}
// glideRequests
// .apply {
// if (!autoplay && data.mimeType == MimeTypes.Gif) {
// // if it's a gif and that we don't auto play,
// // there is no point of loading all frames, just use this to take first one
// asBitmap()
// }
// }
// .apply {
// if (shouldQueryThumb) {
// load(
// contentUrlResolver.resolveThumbnail(data.url, size.width, size.height, ContentUrlResolver.ThumbnailMethod.SCALE)
// ?: data.url.takeIf { it?.startsWith("content://") == true }
// )
// } else {
// load(data).diskCacheStrategy(DiskCacheStrategy.NONE)
// }
// }
// .load(
// if (shouldQueryThumb) {
// contentUrlResolver.resolveThumbnail(data.url, size.width, size.height, ContentUrlResolver.ThumbnailMethod.SCALE)
// ?: data.url.takeIf { it?.startsWith("content://") == true }
// } else {
// data
// }
// )
// cache is handled by the VectorGlideModelLoader
// .diskCacheStrategy(DiskCacheStrategy.NONE)
// .apply {
// if (mode == Mode.THUMBNAIL) {
// error(
// glideRequests.load(resolveUrl(data))
// )
// }
// }
}
}

View File

@@ -64,8 +64,8 @@ class RoomEventsAttachmentProvider(
maxWidth = -1,
width = null,
height = null,
allowNonMxcUrls = it.root.sendState.isSending()
allowNonMxcUrls = it.root.sendState.isSending(),
blurHash = content.info?.blurHash
)
if (content.mimeType == MimeTypes.Gif) {
AttachmentInfo.AnimatedImage(
@@ -91,7 +91,8 @@ class RoomEventsAttachmentProvider(
maxHeight = -1,
width = content.videoInfo?.width,
maxWidth = -1,
allowNonMxcUrls = it.root.sendState.isSending()
allowNonMxcUrls = it.root.sendState.isSending(),
blurHash = content.videoInfo?.blurHash
)
val data = VideoContentRenderer.Data(
eventId = it.eventId,

View File

@@ -141,6 +141,15 @@ class VectorFileLogger @Inject constructor(
logger.info(errors.toString())
}
fun getLogSize(): Int {
return cacheDirectory.walkTopDown()
.onEnter {
Timber.v("Get size of ${it.absolutePath}")
true
}
.sumOf { it.length().toInt() }
}
private fun logToFile(level: String, tag: String, content: String) {
val b = StringBuilder()
b.append(Thread.currentThread().id)

View File

@@ -134,7 +134,8 @@ class RoomUploadsMediaFragment @Inject constructor(
maxHeight = -1,
maxWidth = -1,
width = null,
height = null
height = null,
blurHash = content.info?.blurHash
)
}
is MessageVideoContent -> {
@@ -147,7 +148,8 @@ class RoomUploadsMediaFragment @Inject constructor(
height = content.videoInfo?.height,
maxHeight = -1,
width = content.videoInfo?.width,
maxWidth = -1
maxWidth = -1,
blurHash = content.videoInfo?.blurHash
)
VideoContentRenderer.Data(
eventId = it.eventId,

View File

@@ -123,7 +123,8 @@ class UploadsMediaController @Inject constructor(
height = messageContent.info?.height,
maxHeight = itemSize,
width = messageContent.info?.width,
maxWidth = itemSize
maxWidth = itemSize,
blurHash = messageContent.info?.blurHash
)
}
@@ -139,7 +140,8 @@ class UploadsMediaController @Inject constructor(
height = messageContent.videoInfo?.height,
maxHeight = itemSize,
width = messageContent.videoInfo?.width,
maxWidth = itemSize
maxWidth = itemSize,
blurHash = messageContent.videoInfo?.blurHash
)
return VideoContentRenderer.Data(

View File

@@ -33,6 +33,13 @@ import javax.inject.Inject
class VectorPreferences @Inject constructor(private val context: Context) {
enum class GifAutoPlayPreference(val value:String) {
NEVER("never"),
DM("dm"),
DM_PRIVATE("dm_private"),
ALWAYS("always")
}
companion object {
const val SETTINGS_CHANGE_PASSWORD_PREFERENCE_KEY = "SETTINGS_CHANGE_PASSWORD_PREFERENCE_KEY"
const val SETTINGS_VERSION_PREFERENCE_KEY = "SETTINGS_VERSION_PREFERENCE_KEY"
@@ -43,6 +50,7 @@ class VectorPreferences @Inject constructor(private val context: Context) {
const val SETTINGS_IDENTITY_SERVER_PREFERENCE_KEY = "SETTINGS_IDENTITY_SERVER_PREFERENCE_KEY"
const val SETTINGS_APP_TERM_CONDITIONS_PREFERENCE_KEY = "SETTINGS_APP_TERM_CONDITIONS_PREFERENCE_KEY"
const val SETTINGS_PRIVACY_POLICY_PREFERENCE_KEY = "SETTINGS_PRIVACY_POLICY_PREFERENCE_KEY"
const val SETTINGS_STORAGE_USAGE_PREFERENCE_KEY = "SETTINGS_STORAGE_USAGE_PREFERENCE_KEY"
const val SETTINGS_NOTIFICATION_ADVANCED_PREFERENCE_KEY = "SETTINGS_NOTIFICATION_ADVANCED_PREFERENCE_KEY"
const val SETTINGS_THIRD_PARTY_NOTICES_PREFERENCE_KEY = "SETTINGS_THIRD_PARTY_NOTICES_PREFERENCE_KEY"
@@ -129,6 +137,7 @@ class VectorPreferences @Inject constructor(private val context: Context) {
private const val SETTINGS_DEFAULT_MEDIA_SOURCE_KEY = "SETTINGS_DEFAULT_MEDIA_SOURCE_KEY"
private const val SETTINGS_PREVIEW_MEDIA_BEFORE_SENDING_KEY = "SETTINGS_PREVIEW_MEDIA_BEFORE_SENDING_KEY"
private const val SETTINGS_PLAY_SHUTTER_SOUND_KEY = "SETTINGS_PLAY_SHUTTER_SOUND_KEY"
const val SETTINGS_AUTOPLAY_GIF_PREFERENCE = "SETTINGS_AUTOPLAY_GIF_PREFERENCE"
// background sync
const val SETTINGS_START_ON_BOOT_PREFERENCE_KEY = "SETTINGS_START_ON_BOOT_PREFERENCE_KEY"
@@ -961,6 +970,15 @@ class VectorPreferences @Inject constructor(private val context: Context) {
return defaultPrefs.getBoolean(SETTINGS_LABS_SPACES_HOME_AS_ORPHAN, false)
}
fun getGifAutoPlayPreference(): GifAutoPlayPreference {
return when (defaultPrefs.getString(SETTINGS_AUTOPLAY_GIF_PREFERENCE, "never")) {
"always" -> GifAutoPlayPreference.ALWAYS
"dm" -> GifAutoPlayPreference.DM
"dm_private" -> GifAutoPlayPreference.DM_PRIVATE
else -> GifAutoPlayPreference.NEVER
}
}
/*
* Photo / video picker
*/

View File

@@ -50,6 +50,10 @@ class VectorSettingsPreferencesFragment @Inject constructor(
findPreference<VectorPreference>("SETTINGS_INTERFACE_TAKE_PHOTO_VIDEO")!!
}
private val autoPlayGifPreference by lazy {
findPreference<VectorListPreference>(VectorPreferences.SETTINGS_AUTOPLAY_GIF_PREFERENCE)!!
}
override fun bindPref() {
// user interface preferences
setUserInterfacePreferences()
@@ -67,6 +71,11 @@ class VectorSettingsPreferencesFragment @Inject constructor(
}
}
// set initial value
findPreference<VectorListPreference>(VectorPreferences.SETTINGS_AUTOPLAY_GIF_PREFERENCE)?.let {
it.value = vectorPreferences.getGifAutoPlayPreference().value
}
// Url preview
/*
TODO Note: we keep the setting client side for now

View File

@@ -0,0 +1,158 @@
/*
* Copyright (c) 2021 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.settings.data
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.bumptech.glide.load.engine.cache.DiskCache
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.VectorViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.databinding.FragmentGenericRecyclerBinding
import im.vector.app.features.rageshake.VectorFileLogger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject
data class StorageUsageViewState(
val sizeOfLogs: Async<Long> = Uninitialized,
val sizeOfMediaAndFiles: Async<Long> = Uninitialized,
val sizeOfCache: Async<Long> = Uninitialized,
val sizeOfSessionDatabase: Async<Long> = Uninitialized,
val sizeOfCryptoDatabase: Async<Long> = Uninitialized
) : MvRxState
sealed class StorageUsageViewEvents : VectorViewEvents
sealed class StorageUsageViewModelAction : VectorViewModelAction {
object ClearMediaCache : StorageUsageViewModelAction()
}
class StorageUsageViewModel @AssistedInject constructor(
@Assisted initialState: StorageUsageViewState,
private val session: Session,
private val vectorFileLogger: VectorFileLogger
) : VectorViewModel<StorageUsageViewState, StorageUsageViewModelAction, StorageUsageViewEvents>(initialState) {
init {
viewModelScope.launch(Dispatchers.IO) {
val dlSize = session.fileService().getCacheSize().toLong()
setState {
copy(
sizeOfMediaAndFiles = Success(dlSize)
)
}
}
viewModelScope.launch(Dispatchers.IO) {
val logSize = vectorFileLogger.getLogSize().toLong()
setState {
copy(
sizeOfLogs = Success(logSize)
)
}
}
viewModelScope.launch(Dispatchers.IO) {
val glideCacheSize = session.storageUsageService().cacheDirectorySize(DiskCache.Factory.DEFAULT_DISK_CACHE_DIR)
setState {
copy(
sizeOfCache = Success(glideCacheSize)
)
}
val cyptoSize = session.storageUsageService().cryptoDataBaseSize()
val sessionSize = session.storageUsageService().sessionDataBaseSize()
setState {
copy(
sizeOfCryptoDatabase = Success(cyptoSize),
sizeOfSessionDatabase = Success(sessionSize)
)
}
}
}
@AssistedFactory
interface Factory {
fun create(initialState: StorageUsageViewState): StorageUsageViewModel
}
companion object : MvRxViewModelFactory<StorageUsageViewModel, StorageUsageViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: StorageUsageViewState): StorageUsageViewModel? {
val factory = when (viewModelContext) {
is FragmentViewModelContext -> viewModelContext.fragment as? Factory
is ActivityViewModelContext -> viewModelContext.activity as? Factory
}
return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface")
}
}
override fun handle(action: StorageUsageViewModelAction) {
when (action) {
StorageUsageViewModelAction.ClearMediaCache -> {
}
}.exhaustive
}
}
class DataAndStorageFragment @Inject constructor(
val viewModelFactory: StorageUsageViewModel.Factory,
private val epoxyController: StorageUsageController
) : VectorBaseFragment<FragmentGenericRecyclerBinding>(), StorageUsageViewModel.Factory {
private val viewModel: StorageUsageViewModel by fragmentViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentGenericRecyclerBinding {
return FragmentGenericRecyclerBinding.inflate(inflater, container, false)
}
override fun create(initialState: StorageUsageViewState) = viewModelFactory.create(initialState)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.genericRecyclerView.configureWith(epoxyController, showDivider = true)
}
override fun invalidate() = withState(viewModel) { state ->
epoxyController.setData(state)
}
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright (c) 2021 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.settings.data
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.app.core.ui.list.genericItem
import im.vector.app.core.utils.SizeByteFormatter
import javax.inject.Inject
class StorageUsageController @Inject constructor(
private val sizeByteFormatter: SizeByteFormatter
) : TypedEpoxyController<StorageUsageViewState>() {
override fun buildModels(data: StorageUsageViewState?) {
data ?: return
genericItem {
id("media")
title("Media and Files")
description(
data.sizeOfMediaAndFiles.invoke()?.let {
this@StorageUsageController.sizeByteFormatter.formatFileSize(it)
} ?: "--"
)
}
genericItem {
id("logs")
title("Log Files")
description(
data.sizeOfLogs.invoke()?.let {
this@StorageUsageController.sizeByteFormatter.formatFileSize(it)
} ?: "--"
)
}
genericItem {
id("cache")
title("Media cache")
description(
data.sizeOfCache.invoke()?.let {
this@StorageUsageController.sizeByteFormatter.formatFileSize(it)
} ?: "--"
)
}
genericItem {
id("session")
title("Session Database")
description(
data.sizeOfSessionDatabase.invoke()?.let {
this@StorageUsageController.sizeByteFormatter.formatFileSize(it)
} ?: "--"
)
}
genericItem {
id("crypto_db")
title("Crypto Database")
description(
data.sizeOfCryptoDatabase.invoke()?.let {
this@StorageUsageController.sizeByteFormatter.formatFileSize(it)
} ?: "--"
)
}
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:fromDegrees="270"
android:toDegrees="270">
<!--styling the progress bar-->
<shape
android:innerRadiusRatio="2.5"
android:shape="ring"
android:thickness="3dp"
android:useLevel="true">
<solid android:color="@color/white"/>
<!-- <gradient-->
<!-- android:angle="0"-->
<!-- android:endColor="@color/black"-->
<!-- android:startColor="@color/white"-->
<!-- android:type="sweep"-->
<!-- android:useLevel="false" />-->
</shape>
</rotate>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="0"
android:pivotX="50%"
android:pivotY="50%"
android:toDegrees="1080">
<shape
android:innerRadiusRatio="3"
android:shape="ring"
android:thicknessRatio="10"
android:useLevel="false">
<gradient
android:angle="0"
android:endColor="#80FFFFFF"
android:startColor="#00000000"
android:type="sweep"
android:useLevel="false" />
</shape>
</rotate>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:innerRadiusRatio="2.5"
android:shape="ring"
android:thickness="1dp"
android:useLevel="false">
<solid android:color="#10FFFFFF" />
</shape>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="18"
android:viewportHeight="18">
<path
android:pathData="M9.8,2.7C9.8,2.2582 9.4418,1.9 9,1.9C8.5582,1.9 8.2,2.2582 8.2,2.7L8.2,13.3686L3.7157,8.8843C3.4032,8.5719 2.8967,8.5719 2.5843,8.8843C2.2719,9.1967 2.2719,9.7033 2.5843,10.0157L8.4343,15.8657C8.7467,16.1781 9.2532,16.1781 9.5657,15.8657L15.4157,10.0157C15.7281,9.7033 15.7281,9.1967 15.4157,8.8843C15.1032,8.5719 14.5967,8.5719 14.2843,8.8843L9.8,13.3686L9.8,2.7Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,24C18.6274,24 24,18.6274 24,12C24,5.3726 18.6274,0 12,0C5.3726,0 0,5.3726 0,12C0,18.6274 5.3726,24 12,24ZM10.4709,6.7651C10.3959,5.9101 11.0259,5.1601 11.8809,5.1001C12.7209,5.0401 13.4709,5.6701 13.5609,6.5251V6.7651L13.0809,12.7651C13.0359,13.3201 12.5709,13.7401 12.0159,13.7401H11.9259C11.4009,13.6951 10.9959,13.2901 10.9509,12.7651L10.4709,6.7651ZM13.3202,16.6804C13.3202,17.4094 12.7292,18.0004 12.0002,18.0004C11.2712,18.0004 10.6802,17.4094 10.6802,16.6804C10.6802,15.9513 11.2712,15.3604 12.0002,15.3604C12.7292,15.3604 13.3202,15.9513 13.3202,16.6804Z"
android:fillColor="#8D99A5"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="34dp"
android:height="34dp"
android:viewportWidth="34"
android:viewportHeight="34">
<path
android:pathData="M17,34C7.6112,34 0,26.3888 0,17C0,7.6112 7.6112,0 17,0C26.3888,0 34,7.6112 34,17C34,26.3888 26.3888,34 17,34ZM11.6875,23.7104C11.6875,24.4928 12.5453,24.9721 13.2116,24.5621L24.1161,17.8517C24.7506,17.4612 24.7506,16.5388 24.1161,16.1483L13.2116,9.4379C12.5453,9.0279 11.6875,9.5072 11.6875,10.2896L11.6875,23.7104Z"
android:strokeWidth="1"
android:fillColor="#8E99A4"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
</vector>

View File

@@ -9,28 +9,59 @@
android:id="@+id/messageThumbnailView"
android:layout_width="375dp"
android:layout_height="0dp"
app:layout_constraintHeight_min="60dp"
android:layout_marginEnd="32dp"
android:contentDescription="@string/a11y_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:scaleType="centerCrop"
tools:layout_height="300dp"
tools:src="@tools:sample/backgrounds/scenic" />
<ImageView
android:id="@+id/messageMediaPlayView"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/play_video"
android:src="@drawable/ic_material_play_circle"
<!-- <ProgressBar-->
<!-- android:id="@+id/mediaDownloadProgressBar"-->
<!-- style="?android:attr/progressBarStyleHorizontal"-->
<!-- android:progressTint="?riotx_background"-->
<!-- android:progressBackgroundTint="?riotx_reaction_background_off"-->
<!-- android:layout_width="0dp"-->
<!-- android:layout_height="4dp"-->
<!-- android:visibility="gone"-->
<!-- app:layout_constraintWidth_percent="0.2"-->
<!-- app:layout_constraintBottom_toBottomOf="@id/messageThumbnailView"-->
<!-- app:layout_constraintEnd_toEndOf="@id/messageThumbnailView"-->
<!-- app:layout_constraintStart_toStartOf="@id/messageThumbnailView"-->
<!-- app:layout_constraintTop_toTopOf="@id/messageThumbnailView"-->
<!-- tools:visibility="visible"-->
<!-- android:max="100"-->
<!-- android:min="0"-->
<!-- android:progress="0"-->
<!-- tools:progress="45" />-->
<!-- <ImageView-->
<!-- android:id="@+id/messageMediaPlayView"-->
<!-- android:layout_width="40dp"-->
<!-- android:layout_height="40dp"-->
<!-- android:contentDescription="@string/play_video"-->
<!-- android:src="@drawable/ic_material_play_circle"-->
<!-- android:visibility="gone"-->
<!-- app:layout_constraintBottom_toBottomOf="@id/messageThumbnailView"-->
<!-- app:layout_constraintEnd_toEndOf="@id/messageThumbnailView"-->
<!-- app:layout_constraintStart_toStartOf="@id/messageThumbnailView"-->
<!-- app:layout_constraintTop_toTopOf="@id/messageThumbnailView"-->
<!-- tools:visibility="visible" />-->
<im.vector.app.core.platform.TimelineMediaStateView
android:id="@+id/messageMediaStateView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/messageThumbnailView"
app:layout_constraintEnd_toEndOf="@id/messageThumbnailView"
app:layout_constraintStart_toStartOf="@id/messageThumbnailView"
app:layout_constraintTop_toTopOf="@id/messageThumbnailView"
tools:visibility="visible" />
tools:visibility="visible"/>
<include
android:id="@+id/messageMediaUploadProgressLayout"

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:parentTag="android.widget.FrameLayout">
<RelativeLayout
android:layout_width="60dp"
android:layout_height="60dp">
<ImageView
android:id="@+id/mediaStateNotDownloaded"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_centerInParent="true"
android:contentDescription="@string/a11y_download_media"
android:src="@drawable/ic_download"
app:tint="@color/white" />
<FrameLayout
android:id="@+id/circularProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true">
<include android:id="@+id/mediaProgressView" layout="@layout/view_media_progress" />
</FrameLayout>
<!-- <ProgressBar-->
<!-- android:id="@+id/mediaDownloadProgressBar"-->
<!-- style="?android:attr/progressBarStyleHorizontal"-->
<!-- android:layout_width="40dp"-->
<!-- android:layout_height="6dp"-->
<!-- android:layout_centerInParent="true"-->
<!-- android:max="100"-->
<!-- android:min="0"-->
<!-- android:progress="0"-->
<!-- android:progressBackgroundTint="@color/white"-->
<!-- android:progressTint="@color/white"-->
<!-- android:visibility="gone"-->
<!-- tools:progress="45"-->
<!-- tools:visibility="visible" />-->
<ImageView
android:id="@+id/mediaStateError"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_centerInParent="true"
android:contentDescription="@string/a11y_media_permanent_error"
android:src="@drawable/ic_media_error" />
<ImageView
android:id="@+id/mediaStatePlay"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_centerInParent="true"
android:contentDescription="@string/play_video"
android:src="@drawable/ic_media_play"
app:tint="?riotx_reaction_background_off" />
</RelativeLayout>
</merge>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/mediaProgressView"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/circle"
android:backgroundTint="@color/black_alpha">
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/circular_progress_shape"
android:indeterminate="false"
android:progressDrawable="@drawable/circular_progress_bar"
android:indeterminateDrawable="@drawable/circular_progress_bar_indeterminate"
android:textAlignment="center"
tools:max="100"
tools:progress="40" />
<!-- cancel, autodownload off not yet managed -->
<ImageView
android:visibility="gone"
android:id="@+id/progressIconImage"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_centerInParent="true"
android:src="@drawable/ic_cross"
app:tint="@color/white"
tools:contentDescription="@string/play_video"
tools:src="@drawable/ic_down_arrow" />
</RelativeLayout>

View File

@@ -16,6 +16,20 @@
<item>3</item>
<item>4</item>
</string-array>
<string-array name="autoplay_gif">
<item>@string/auto_play_never</item>
<item>@string/auto_play_direct_message</item>
<item>@string/auto_play_direct_messages_and_private_rooms</item>
<item>@string/auto_play_always</item>
</string-array>
<string-array name="autoplay_gif_values">
<item>never</item>
<item>dm</item>
<item>dm_private</item>
<item>always</item>
</string-array>
<string-array name="media_sources">
<item>@string/media_source_choose</item>
<item>@string/option_send_files</item>

View File

@@ -692,6 +692,13 @@
<string name="compression_opt_list_medium">Medium</string>
<string name="compression_opt_list_small">Small</string>
<!-- Autoplay gif option -->
<string name="auto_play_never">Never</string>
<string name="auto_play_direct_message">In Direct Messages</string>
<string name="auto_play_direct_messages_and_private_rooms">In Direct Messages and Private Rooms</string>
<string name="auto_play_always">Always</string>
<!-- media upload / download messages -->
<string name="attachment_cancel_download">"Cancel the download?</string>
<string name="attachment_cancel_upload">Cancel the upload?</string>
@@ -1223,6 +1230,7 @@
<string name="settings_keep_media">Keep media</string>
<string name="settings_clear_cache">Clear cache</string>
<string name="settings_clear_media_cache">Clear media cache</string>
<string name="settings_storage_usage">Storage Usage</string>
<string name="settings_user_settings">User settings</string>
<string name="settings_notifications">Notifications</string>
@@ -1370,6 +1378,7 @@
<!-- Media settings -->
<string name="settings_media">Media</string>
<string name="settings_default_compression">Default compression</string>
<string name="settings_autoplay_gif">Autoplay Gif</string>
<string name="compression_opt_list_choose">Choose</string>
<string name="settings_default_media_source">Default media source</string>
<string name="media_source_choose">Choose</string>
@@ -3270,6 +3279,8 @@
<string name="a11y_view_read_receipts">View read receipts</string>
<string name="a11y_public_room">This room is public</string>
<string name="a11y_public_space">This Space is public</string>
<string name="a11y_download_media">Tap to download media</string>
<string name="a11y_media_permanent_error">Failed to read media</string>
<string name="dev_tools_menu_name">Dev Tools</string>
<string name="dev_tools_explore_room_state">Explore Room State</string>

View File

@@ -94,6 +94,11 @@
android:key="SETTINGS_CLEAR_MEDIA_CACHE_PREFERENCE_KEY"
android:title="@string/settings_clear_media_cache" />
<im.vector.app.core.preference.VectorPreference
android:key="SETTINGS_STORAGE_USAGE_PREFERENCE_KEY"
android:title="@string/settings_storage_usage"
app:fragment="im.vector.app.features.settings.data.DataAndStorageFragment" />
<im.vector.app.core.preference.VectorPreference
android:key="SETTINGS_CLEAR_CACHE_PREFERENCE_KEY"
android:title="@string/settings_clear_cache" />

View File

@@ -130,6 +130,14 @@
android:title="@string/settings_vibrate_on_mention"
app:isPreferenceVisible="@bool/false_not_implemented" />
<im.vector.app.core.preference.VectorListPreference
android:defaultValue="0"
android:entries="@array/autoplay_gif"
android:entryValues="@array/autoplay_gif_values"
android:key="SETTINGS_AUTOPLAY_GIF_PREFERENCE"
android:summary="%s"
android:title="@string/settings_autoplay_gif" />
</im.vector.app.core.preference.VectorPreferenceCategory>
<im.vector.app.core.preference.VectorPreferenceCategory