Merge pull request #2049 from vector-im/feature/image_compression
Image compression
This commit is contained in:
commit
751c870a4a
|
@ -24,6 +24,7 @@ Bugfix 🐛:
|
||||||
- Can't handle ongoing call events in background (#1992)
|
- Can't handle ongoing call events in background (#1992)
|
||||||
- Crash / Attachment viewer: Cannot draw a recycled Bitmap #2034
|
- Crash / Attachment viewer: Cannot draw a recycled Bitmap #2034
|
||||||
- Login with Matrix-Id | Autodiscovery fails if identity server is invalid and Homeserver ok (#2027)
|
- Login with Matrix-Id | Autodiscovery fails if identity server is invalid and Homeserver ok (#2027)
|
||||||
|
- Support for image compression on Android 10
|
||||||
- Verification popup won't show
|
- Verification popup won't show
|
||||||
|
|
||||||
Translations 🗣:
|
Translations 🗣:
|
||||||
|
|
|
@ -144,7 +144,6 @@ dependencies {
|
||||||
|
|
||||||
// Image
|
// Image
|
||||||
implementation 'androidx.exifinterface:exifinterface:1.3.0-alpha01'
|
implementation 'androidx.exifinterface:exifinterface:1.3.0-alpha01'
|
||||||
implementation 'id.zelory:compressor:3.0.0'
|
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
implementation 'com.github.Zhuinden:realm-monarchy:0.5.1'
|
implementation 'com.github.Zhuinden:realm-monarchy:0.5.1'
|
||||||
|
|
|
@ -0,0 +1,125 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2020 New Vector Ltd
|
||||||
|
* 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.content
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
internal class ImageCompressor @Inject constructor() {
|
||||||
|
suspend fun compress(
|
||||||
|
context: Context,
|
||||||
|
imageFile: File,
|
||||||
|
desiredWidth: Int,
|
||||||
|
desiredHeight: Int,
|
||||||
|
desiredQuality: Int = 80): File {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val compressedBitmap = BitmapFactory.Options().run {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
decodeBitmap(imageFile, this)
|
||||||
|
inSampleSize = calculateInSampleSize(outWidth, outHeight, desiredWidth, desiredHeight)
|
||||||
|
inJustDecodeBounds = false
|
||||||
|
decodeBitmap(imageFile, this)?.let {
|
||||||
|
rotateBitmap(imageFile, it)
|
||||||
|
}
|
||||||
|
} ?: return@withContext imageFile
|
||||||
|
|
||||||
|
val destinationFile = createDestinationFile(context)
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
destinationFile.outputStream().use {
|
||||||
|
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, desiredQuality, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext destinationFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rotateBitmap(file: File, bitmap: Bitmap): Bitmap {
|
||||||
|
file.inputStream().use { inputStream ->
|
||||||
|
try {
|
||||||
|
ExifInterface(inputStream).let { exifInfo ->
|
||||||
|
val orientation = exifInfo.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||||
|
val matrix = Matrix()
|
||||||
|
when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
|
matrix.preRotate(-90f)
|
||||||
|
matrix.preScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
|
matrix.preRotate(90f)
|
||||||
|
matrix.preScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
else -> return bitmap
|
||||||
|
}
|
||||||
|
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Cannot read orientation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://developer.android.com/topic/performance/graphics/load-bitmap
|
||||||
|
private fun calculateInSampleSize(width: Int, height: Int, desiredWidth: Int, desiredHeight: Int): Int {
|
||||||
|
var inSampleSize = 1
|
||||||
|
|
||||||
|
if (width > desiredWidth || height > desiredHeight) {
|
||||||
|
val halfHeight: Int = height / 2
|
||||||
|
val halfWidth: Int = width / 2
|
||||||
|
|
||||||
|
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
|
||||||
|
// height and width larger than the requested height and width.
|
||||||
|
while (halfHeight / inSampleSize >= desiredHeight && halfWidth / inSampleSize >= desiredWidth) {
|
||||||
|
inSampleSize *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inSampleSize
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeBitmap(file: File, options: BitmapFactory.Options = BitmapFactory.Options()): Bitmap? {
|
||||||
|
return try {
|
||||||
|
file.inputStream().use { inputStream ->
|
||||||
|
BitmapFactory.decodeStream(inputStream, null, options)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Cannot decode Bitmap")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDestinationFile(context: Context): File {
|
||||||
|
return File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,8 +22,6 @@ import android.graphics.BitmapFactory
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.squareup.moshi.JsonClass
|
import com.squareup.moshi.JsonClass
|
||||||
import id.zelory.compressor.Compressor
|
|
||||||
import id.zelory.compressor.constraint.default
|
|
||||||
import org.matrix.android.sdk.api.extensions.tryThis
|
import org.matrix.android.sdk.api.extensions.tryThis
|
||||||
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
|
||||||
|
@ -74,6 +72,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
||||||
@Inject lateinit var contentUploadStateTracker: DefaultContentUploadStateTracker
|
@Inject lateinit var contentUploadStateTracker: DefaultContentUploadStateTracker
|
||||||
@Inject lateinit var fileService: DefaultFileService
|
@Inject lateinit var fileService: DefaultFileService
|
||||||
@Inject lateinit var cancelSendTracker: CancelSendTracker
|
@Inject lateinit var cancelSendTracker: CancelSendTracker
|
||||||
|
@Inject lateinit var imageCompressor: ImageCompressor
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val params = WorkerParamsFactory.fromData<Params>(inputData)
|
val params = WorkerParamsFactory.fromData<Params>(inputData)
|
||||||
|
@ -101,10 +100,6 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
||||||
val sessionComponent = getSessionComponent(params.sessionId) ?: return Result.success()
|
val sessionComponent = getSessionComponent(params.sessionId) ?: return Result.success()
|
||||||
sessionComponent.inject(this)
|
sessionComponent.inject(this)
|
||||||
|
|
||||||
val attachment = params.attachment
|
|
||||||
|
|
||||||
var newImageAttributes: NewImageAttributes? = null
|
|
||||||
|
|
||||||
val allCancelled = params.events.all { cancelSendTracker.isCancelRequestedFor(it.eventId, it.roomId) }
|
val allCancelled = params.events.all { cancelSendTracker.isCancelRequestedFor(it.eventId, it.roomId) }
|
||||||
if (allCancelled) {
|
if (allCancelled) {
|
||||||
// there is no point in uploading the image!
|
// there is no point in uploading the image!
|
||||||
|
@ -112,6 +107,9 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
||||||
.also { Timber.e("## Send: Work cancelled by user") }
|
.also { Timber.e("## Send: Work cancelled by user") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val attachment = params.attachment
|
||||||
|
val filesToDelete = mutableListOf<File>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val inputStream = context.contentResolver.openInputStream(attachment.queryUri)
|
val inputStream = context.contentResolver.openInputStream(attachment.queryUri)
|
||||||
?: return Result.success(
|
?: return Result.success(
|
||||||
|
@ -124,43 +122,14 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
||||||
|
|
||||||
// always use a temporary file, it guaranties that we could report progress on upload and simplifies the flows
|
// always use a temporary file, it guaranties that we could report progress on upload and simplifies the flows
|
||||||
val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
|
val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
|
||||||
workingFile.outputStream().use {
|
.also { filesToDelete.add(it) }
|
||||||
inputStream.copyTo(it)
|
workingFile.outputStream().use { outputStream ->
|
||||||
}
|
inputStream.use { inputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
// inputStream.use {
|
|
||||||
var uploadedThumbnailUrl: String? = null
|
|
||||||
var uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo? = null
|
|
||||||
|
|
||||||
ThumbnailExtractor.extractThumbnail(context, params.attachment)?.let { thumbnailData ->
|
|
||||||
val thumbnailProgressListener = object : ProgressRequestBody.Listener {
|
|
||||||
override fun onProgress(current: Long, total: Long) {
|
|
||||||
notifyTracker(params) { contentUploadStateTracker.setProgressThumbnail(it, current, total) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
val uploadThumbnailResult = dealWithThumbnail(params)
|
||||||
val contentUploadResponse = if (params.isEncrypted) {
|
|
||||||
Timber.v("Encrypt thumbnail")
|
|
||||||
notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) }
|
|
||||||
val encryptionResult = MXEncryptedAttachments.encryptAttachment(thumbnailData.bytes.inputStream(), thumbnailData.mimeType)
|
|
||||||
uploadedThumbnailEncryptedFileInfo = encryptionResult.encryptedFileInfo
|
|
||||||
fileUploader.uploadByteArray(encryptionResult.encryptedByteArray,
|
|
||||||
"thumb_${attachment.name}",
|
|
||||||
"application/octet-stream",
|
|
||||||
thumbnailProgressListener)
|
|
||||||
} else {
|
|
||||||
fileUploader.uploadByteArray(thumbnailData.bytes,
|
|
||||||
"thumb_${attachment.name}",
|
|
||||||
thumbnailData.mimeType,
|
|
||||||
thumbnailProgressListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadedThumbnailUrl = contentUploadResponse.contentUri
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Timber.e(t, "Thumbnail update failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val progressListener = object : ProgressRequestBody.Listener {
|
val progressListener = object : ProgressRequestBody.Listener {
|
||||||
override fun onProgress(current: Long, total: Long) {
|
override fun onProgress(current: Long, total: Long) {
|
||||||
|
@ -177,40 +146,37 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
||||||
var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null
|
var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val fileToUplaod: File
|
val fileToUpload: File
|
||||||
|
var newImageAttributes: NewImageAttributes? = null
|
||||||
|
|
||||||
if (attachment.type == ContentAttachmentData.Type.IMAGE && params.compressBeforeSending) {
|
if (attachment.type == ContentAttachmentData.Type.IMAGE && params.compressBeforeSending) {
|
||||||
// Compressor library works with File instead of Uri for now. Since Scoped Storage doesn't allow us to access files directly, we should
|
fileToUpload = imageCompressor.compress(context, workingFile, MAX_IMAGE_SIZE, MAX_IMAGE_SIZE)
|
||||||
// copy it to a cache folder by using InputStream and OutputStream.
|
.also { compressedFile ->
|
||||||
// https://github.com/zetbaitsu/Compressor/pull/150
|
// Get new Bitmap size
|
||||||
// As soon as the above PR is merged, we can use attachment.queryUri instead of creating a cacheFile.
|
compressedFile.inputStream().use {
|
||||||
val compressedFile = Compressor.compress(context, workingFile) {
|
|
||||||
default(
|
|
||||||
width = MAX_IMAGE_SIZE,
|
|
||||||
height = MAX_IMAGE_SIZE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
BitmapFactory.decodeFile(compressedFile.absolutePath, options)
|
val bitmap = BitmapFactory.decodeStream(it, null, options)
|
||||||
val fileSize = compressedFile.length().toInt()
|
val fileSize = bitmap?.byteCount ?: 0
|
||||||
newImageAttributes = NewImageAttributes(
|
newImageAttributes = NewImageAttributes(
|
||||||
options.outWidth,
|
options.outWidth,
|
||||||
options.outHeight,
|
options.outHeight,
|
||||||
fileSize
|
fileSize
|
||||||
)
|
)
|
||||||
fileToUplaod = compressedFile
|
}
|
||||||
|
}
|
||||||
|
.also { filesToDelete.add(it) }
|
||||||
} else {
|
} else {
|
||||||
fileToUplaod = workingFile
|
fileToUpload = workingFile
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
val tmpEncrypted = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
|
||||||
|
.also { filesToDelete.add(it) }
|
||||||
|
|
||||||
uploadedFileEncryptedFileInfo =
|
uploadedFileEncryptedFileInfo =
|
||||||
MXEncryptedAttachments.encrypt(fileToUplaod.inputStream(), attachment.getSafeMimeType(), tmpEncrypted) { read, total ->
|
MXEncryptedAttachments.encrypt(fileToUpload.inputStream(), attachment.getSafeMimeType(), tmpEncrypted) { read, total ->
|
||||||
notifyTracker(params) {
|
notifyTracker(params) {
|
||||||
contentUploadStateTracker.setEncrypting(it, read.toLong(), total.toLong())
|
contentUploadStateTracker.setEncrypting(it, read.toLong(), total.toLong())
|
||||||
}
|
}
|
||||||
|
@ -220,14 +186,10 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
||||||
|
|
||||||
fileUploader
|
fileUploader
|
||||||
.uploadFile(tmpEncrypted, attachment.name, "application/octet-stream", progressListener)
|
.uploadFile(tmpEncrypted, attachment.name, "application/octet-stream", progressListener)
|
||||||
.also {
|
|
||||||
// we can delete?
|
|
||||||
tryThis { tmpEncrypted.delete() }
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Timber.v("## FileService: Clear file")
|
Timber.v("## FileService: Clear file")
|
||||||
fileUploader
|
fileUploader
|
||||||
.uploadFile(fileToUplaod, 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}")
|
||||||
|
@ -237,14 +199,14 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
||||||
}
|
}
|
||||||
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 fileservice cache")
|
Timber.e(failure, "## FileService: Failed to update file cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSuccess(params,
|
handleSuccess(params,
|
||||||
contentUploadResponse.contentUri,
|
contentUploadResponse.contentUri,
|
||||||
uploadedFileEncryptedFileInfo,
|
uploadedFileEncryptedFileInfo,
|
||||||
uploadedThumbnailUrl,
|
uploadThumbnailResult?.uploadedThumbnailUrl,
|
||||||
uploadedThumbnailEncryptedFileInfo,
|
uploadThumbnailResult?.uploadedThumbnailEncryptedFileInfo,
|
||||||
newImageAttributes)
|
newImageAttributes)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Timber.e(t, "## FileService: ERROR ${t.localizedMessage}")
|
Timber.e(t, "## FileService: ERROR ${t.localizedMessage}")
|
||||||
|
@ -260,6 +222,58 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
} finally {
|
||||||
|
// Delete all temporary files
|
||||||
|
filesToDelete.forEach {
|
||||||
|
tryThis { it.delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class UploadThumbnailResult(
|
||||||
|
val uploadedThumbnailUrl: String,
|
||||||
|
val uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If appropriate, it will create and upload a thumbnail
|
||||||
|
*/
|
||||||
|
private suspend fun dealWithThumbnail(params: Params): UploadThumbnailResult? {
|
||||||
|
return ThumbnailExtractor.extractThumbnail(context, params.attachment)
|
||||||
|
?.let { thumbnailData ->
|
||||||
|
val thumbnailProgressListener = object : ProgressRequestBody.Listener {
|
||||||
|
override fun onProgress(current: Long, total: Long) {
|
||||||
|
notifyTracker(params) { contentUploadStateTracker.setProgressThumbnail(it, current, total) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (params.isEncrypted) {
|
||||||
|
Timber.v("Encrypt thumbnail")
|
||||||
|
notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) }
|
||||||
|
val encryptionResult = MXEncryptedAttachments.encryptAttachment(thumbnailData.bytes.inputStream(), thumbnailData.mimeType)
|
||||||
|
val contentUploadResponse = fileUploader.uploadByteArray(encryptionResult.encryptedByteArray,
|
||||||
|
"thumb_${params.attachment.name}",
|
||||||
|
"application/octet-stream",
|
||||||
|
thumbnailProgressListener)
|
||||||
|
UploadThumbnailResult(
|
||||||
|
contentUploadResponse.contentUri,
|
||||||
|
encryptionResult.encryptedFileInfo
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val contentUploadResponse = fileUploader.uploadByteArray(thumbnailData.bytes,
|
||||||
|
"thumb_${params.attachment.name}",
|
||||||
|
thumbnailData.mimeType,
|
||||||
|
thumbnailProgressListener)
|
||||||
|
UploadThumbnailResult(
|
||||||
|
contentUploadResponse.contentUri,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Timber.e(t, "Thumbnail upload failed")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -313,11 +313,6 @@ SOFTWARE.
|
||||||
<br/>
|
<br/>
|
||||||
Copyright (c) 2012-2016 Dan Wheeler and Dropbox, Inc.
|
Copyright (c) 2012-2016 Dan Wheeler and Dropbox, Inc.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<b>Compressor</b>
|
|
||||||
<br/>
|
|
||||||
Copyright (c) 2016 Zetra.
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<b>com.otaliastudios:autocomplete</b>
|
<b>com.otaliastudios:autocomplete</b>
|
||||||
<br/>
|
<br/>
|
||||||
|
|
Loading…
Reference in New Issue