refactor: Improve IO performance and simplify code with Okio (#769)

`ImageDownsizer.downsizeImage()`:
- Remove the return value, it was ignored
- Throw `FileNotFoundException` when `openInputStream` returns null

`ImageDownsizer.getImageOrientation()`:
- Throw `FileNotFoundException` when `openInputStream` returns null

`MediaUploader.prepareMedia()`:
- Copy URI contents using Okio buffers / source / sink

`UriExtensions`:
- Rename from `IOUtils`
- Implement `Uri.copyToFile()` using Okio buffers / source / sink
- Replace `ProgressRequestBody()` with `Uri.asRequestBody()` using Okio
buffers / source / sink

`DraftHelper.copyToFolder()`
- Use Okio buffers / source / sink

`CompositeWithOpaqueBackground`
- Use constants `SIZE_BYTES` and `CHARSET` instead of magic values
- Use `Objects.hash` when hashing multiple objects

Based on work by Christophe Beyls in
- https://github.com/tuskyapp/Tusky/pull/4366
- https://github.com/tuskyapp/Tusky/pull/4372
This commit is contained in:
Nik Clayton 2024-06-20 13:18:58 +02:00 committed by GitHub
parent 8877482abc
commit f3354d1aae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 159 additions and 207 deletions

View File

@ -151,6 +151,7 @@ dependencies {
implementation(libs.bundles.retrofit)
implementation(libs.bundles.okhttp)
implementation(libs.okio)
implementation(libs.conscrypt.android)

View File

@ -105,11 +105,11 @@
<issue
id="StringFormatInvalid"
message="Format string &apos;`app_name`&apos; is not a valid format string so it should not be passed to `String.format`"
errorLine1=" val filename = &quot;%s_%s_%s.%s&quot;.format("
errorLine1=" val filename = &quot;%s_%d_%s.%s&quot;.format("
errorLine2=" ^">
<location
file="src/main/java/app/pachli/components/compose/MediaUploader.kt"
line="271"
line="268"
column="28"/>
<location
file="${:core:activity*buildDir}/generated/res/resValues/orangeFdroid/debug/values/gradleResValues.xml"

View File

@ -20,8 +20,8 @@ import android.content.ContentResolver
import android.graphics.Bitmap.CompressFormat
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import app.pachli.util.calculateInSampleSize
import app.pachli.util.getImageOrientation
import app.pachli.util.reorientBitmap
import java.io.File
import java.io.FileNotFoundException
@ -32,25 +32,26 @@ import java.io.FileOutputStream
* @param sizeLimit the maximum number of bytes the output image is allowed to have
* @param contentResolver to resolve the specified input uri
* @param tempFile the file where the result will be stored
* @return true when the image was successfully resized, false otherwise
* @throws FileNotFoundException if [uri] could not be opened.
*/
fun downsizeImage(
uri: Uri,
sizeLimit: Long,
contentResolver: ContentResolver,
tempFile: File,
): Boolean {
val decodeBoundsInputStream = try {
contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {
return false
}
) {
// Initially, just get the image dimensions.
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
decodeBoundsInputStream.use { BitmapFactory.decodeStream(it, null, options) }
val inputStream = contentResolver.openInputStream(uri)
?: throw FileNotFoundException("openInputStream returned null")
inputStream.use { input ->
options.inJustDecodeBounds = true
BitmapFactory.decodeStream(input, null, options)
}
// Get EXIF data, for orientation info.
val orientation = getImageOrientation(uri, contentResolver)
/* Unfortunately, there isn't a determined worst case compression ratio for image
* formats. So, the only way to tell if they're too big is to compress them and
* test, and keep trying at smaller sizes. The initial estimate should be good for
@ -58,27 +59,20 @@ fun downsizeImage(
* sure it gets downsized to below the limit. */
var scaledImageSize = 1024
do {
val outputStream = try {
FileOutputStream(tempFile)
} catch (e: FileNotFoundException) {
return false
}
val decodeBitmapInputStream = try {
contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {
return false
}
val outputStream = FileOutputStream(tempFile)
val decodeBitmapInputStream = contentResolver.openInputStream(uri)
?: throw FileNotFoundException("openInputStream returned null")
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize)
options.inJustDecodeBounds = false
val scaledBitmap = decodeBitmapInputStream.use {
BitmapFactory.decodeStream(it, null, options)
} ?: return false
} ?: return
val reorientedBitmap = reorientBitmap(scaledBitmap, orientation)
if (reorientedBitmap == null) {
scaledBitmap.recycle()
return false
return
}
/* Retain transparency if there is any by encoding as png */
val format: CompressFormat = if (!reorientedBitmap.hasAlpha()) {
@ -90,6 +84,20 @@ fun downsizeImage(
reorientedBitmap.recycle()
scaledImageSize /= 2
} while (tempFile.length() > sizeLimit)
return true
}
/**
* @return The EXIF orientation of the image at the local [uri].
* @throws FileNotFoundException if [uri] could not be opened.
*/
private fun getImageOrientation(uri: Uri, contentResolver: ContentResolver): Int {
val inputStream = contentResolver.openInputStream(uri)
?: throw FileNotFoundException("openInputStream returned null")
return inputStream.use { input ->
ExifInterface(input).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}
}

View File

@ -16,7 +16,6 @@
package app.pachli.components.compose
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.media.MediaMetadataRetriever
@ -33,14 +32,12 @@ import app.pachli.core.common.string.randomAlphanumericString
import app.pachli.core.data.model.InstanceInfo
import app.pachli.core.network.model.MediaUploadApi
import app.pachli.core.ui.extensions.getErrorString
import app.pachli.network.ProgressRequestBody
import app.pachli.util.MEDIA_SIZE_UNKNOWN
import app.pachli.util.asRequestBody
import app.pachli.util.getImageSquarePixels
import app.pachli.util.getMediaSize
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@ -60,6 +57,9 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.shareIn
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okio.buffer
import okio.sink
import okio.source
import retrofit2.HttpException
import timber.log.Timber
@ -167,22 +167,20 @@ class MediaUploader @Inject constructor(
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
contentResolver.openInputStream(inUri).use { input ->
contentResolver.openInputStream(inUri)?.source()?.buffer().use { input ->
if (input == null) {
Timber.w("Media input is null")
uri = inUri
return@use
}
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file,
)
mediaSize = getMediaSize(contentResolver, uri)
}
file.absoluteFile.sink().buffer().use { it.writeAll(input) }
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file,
)
mediaSize = getMediaSize(contentResolver, uri)
}
}
ContentResolver.SCHEME_FILE -> {
@ -195,17 +193,16 @@ class MediaUploader @Inject constructor(
val suffix = inputFile.name.substringAfterLast('.', "tmp")
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
val input = FileInputStream(inputFile)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file,
)
mediaSize = getMediaSize(contentResolver, uri)
inputFile.source().buffer().use { input ->
file.absoluteFile.sink().buffer().use { it.writeAll(input) }
}
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file,
)
mediaSize = getMediaSize(contentResolver, uri)
}
else -> {
Timber.w("Unknown uri scheme %s", uri)
@ -268,24 +265,20 @@ class MediaUploader @Inject constructor(
}
val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType)
val filename = "%s_%s_%s.%s".format(
val filename = "%s_%d_%s.%s".format(
context.getString(R.string.app_name),
System.currentTimeMillis().toString(),
System.currentTimeMillis(),
randomAlphanumericString(10),
fileExtension,
)
// `stream` is closed in ProgressRequestBody.writeTo
@SuppressLint("recycle")
val stream = contentResolver.openInputStream(media.uri)
if (mimeType == null) mimeType = "multipart/form-data"
var lastProgress = -1
val fileBody = ProgressRequestBody(
stream!!,
val fileBody = media.uri.asRequestBody(
contentResolver,
requireNotNull(mimeType.toMediaTypeOrNull()) { "Invalid Content Type" },
media.mediaSize,
mimeType.toMediaTypeOrNull()!!,
) { percentage ->
if (percentage != lastProgress) {
trySend(UploadEvent.ProgressEvent(percentage))

View File

@ -182,15 +182,10 @@ class DraftHelper @Inject constructor(
// saving redrafted media
try {
val request = Request.Builder().url(toString()).build()
val response = okHttpClient.newCall(request).execute()
val sink = file.sink().buffer()
response.body?.source()?.use { input ->
sink.use { output ->
output.writeAll(input)
}
response.body?.source()?.buffer?.use { input ->
file.sink().buffer().use { it.writeAll(input) }
}
} catch (ex: IOException) {
Timber.w(ex, "failed to save media")

View File

@ -1,54 +0,0 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.network
import java.io.IOException
import java.io.InputStream
import okhttp3.MediaType
import okhttp3.RequestBody
import okio.BufferedSink
class ProgressRequestBody(
private val content: InputStream,
private val contentLength: Long,
private val mediaType: MediaType,
private val uploadListener: (Int) -> Unit,
) : RequestBody() {
override fun contentType(): MediaType {
return mediaType
}
@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var uploaded: Long = 0
content.use { content ->
var read: Int
while (content.read(buffer).also { read = it } != -1) {
uploadListener((100 * uploaded / contentLength).toInt())
uploaded += read.toLong()
sink.write(buffer, 0, read)
}
uploadListener((100 * uploaded / contentLength).toInt())
}
}
companion object {
private const val DEFAULT_BUFFER_SIZE = 2048
}
}

View File

@ -25,12 +25,12 @@ import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.graphics.Shader
import androidx.annotation.ColorInt
import com.bumptech.glide.load.Key
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import com.bumptech.glide.util.Util
import java.nio.ByteBuffer
import java.nio.charset.Charset
import java.security.MessageDigest
import java.util.Objects
/**
* Set an opaque background behind the non-transparent areas of a bitmap.
@ -58,10 +58,11 @@ class CompositeWithOpaqueBackground(@ColorInt val backgroundColor: Int) : Bitmap
return false
}
override fun hashCode() = Util.hashCode(ID.hashCode(), backgroundColor.hashCode())
override fun hashCode() = Objects.hash(ID, backgroundColor)
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(ID_BYTES)
messageDigest.update(ByteBuffer.allocate(4).putInt(backgroundColor.hashCode()).array())
messageDigest.update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(backgroundColor).array())
}
override fun transform(
@ -109,7 +110,7 @@ class CompositeWithOpaqueBackground(@ColorInt val backgroundColor: Int) : Bitmap
companion object {
private val ID = CompositeWithOpaqueBackground::class.qualifiedName!!
private val ID_BYTES = ID.toByteArray(Charset.forName("UTF-8"))
private val ID_BYTES = ID.toByteArray(Key.CHARSET)
/** Paint with a color filter that converts 8bpp alpha images to a 1bpp mask */
private val EXTRACT_MASK_PAINT = Paint().apply {

View File

@ -1,50 +0,0 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.util
import android.content.ContentResolver
import android.net.Uri
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
private const val DEFAULT_BLOCKSIZE = 16384
fun Uri.copyToFile(
contentResolver: ContentResolver,
file: File,
): Boolean {
try {
contentResolver.openInputStream(this).use { from ->
from ?: return false
FileOutputStream(file).use { to ->
val chunk = ByteArray(DEFAULT_BLOCKSIZE)
while (true) {
val bytes = from.read(chunk, 0, chunk.size)
if (bytes < 0) break
to.write(chunk, 0, bytes)
}
}
}
} catch (e: FileNotFoundException) {
return false
}
return true
}

View File

@ -25,11 +25,9 @@ import android.net.Uri
import android.provider.OpenableColumns
import androidx.exifinterface.media.ExifInterface
import java.io.FileNotFoundException
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import timber.log.Timber
/**
* Helper methods for obtaining and resizing media files
@ -45,9 +43,7 @@ const val MEDIA_SIZE_UNKNOWN = -1L
* @return the size of the media in bytes or {@link MediaUtils#MEDIA_SIZE_UNKNOWN}
*/
fun getMediaSize(contentResolver: ContentResolver, uri: Uri?): Long {
if (uri == null) {
return MEDIA_SIZE_UNKNOWN
}
uri ?: return MEDIA_SIZE_UNKNOWN
var mediaSize = MEDIA_SIZE_UNKNOWN
val cursor: Cursor?
@ -66,14 +62,14 @@ fun getMediaSize(contentResolver: ContentResolver, uri: Uri?): Long {
}
@Throws(FileNotFoundException::class)
fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Long {
fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Int {
val options = BitmapFactory.Options()
contentResolver.openInputStream(uri).use { input ->
options.inJustDecodeBounds = true
BitmapFactory.decodeStream(input, null, options)
}
return (options.outWidth * options.outHeight).toLong()
return options.outWidth * options.outHeight
}
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
@ -119,9 +115,7 @@ fun reorientBitmap(bitmap: Bitmap?, orientation: Int): Bitmap? {
else -> return bitmap
}
if (bitmap == null) {
return null
}
bitmap ?: return null
return try {
val result = Bitmap.createBitmap(
@ -142,27 +136,6 @@ fun reorientBitmap(bitmap: Bitmap?, orientation: Int): Bitmap? {
}
}
fun getImageOrientation(uri: Uri, contentResolver: ContentResolver): Int {
val inputStream = try {
contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {
Timber.w(e)
return ExifInterface.ORIENTATION_UNDEFINED
}
inputStream ?: return ExifInterface.ORIENTATION_UNDEFINED
return inputStream.use {
val exifInterface = try {
ExifInterface(it)
} catch (e: IOException) {
Timber.w(e)
return@use ExifInterface.ORIENTATION_UNDEFINED
}
exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
}
}
fun getTemporaryMediaFilename(extension: String): String {
return "${MEDIA_TEMP_PREFIX}_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.$extension"
}

View File

@ -0,0 +1,83 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.util
import android.content.ContentResolver
import android.net.Uri
import java.io.File
import java.io.IOException
import okhttp3.MediaType
import okhttp3.RequestBody
import okio.Buffer
import okio.BufferedSink
import okio.FileNotFoundException
import okio.buffer
import okio.sink
import okio.source
fun Uri.copyToFile(contentResolver: ContentResolver, file: File): Boolean {
return try {
val inputStream = contentResolver.openInputStream(this) ?: return false
inputStream.source().buffer().use { source ->
file.sink().buffer().use { it.writeAll(source) }
}
true
} catch (e: IOException) {
false
}
}
// Aligned with okio.Segment.SIZE (internal) for better performance
private const val DEFAULT_CHUNK_SIZE = 8192L
fun interface UploadCallback {
fun onProgressUpdate(percentage: Int)
}
fun Uri.asRequestBody(
contentResolver: ContentResolver,
contentType: MediaType? = null,
contentLength: Long = -1L,
uploadListener: UploadCallback? = null,
): RequestBody {
return object : RequestBody() {
override fun contentType(): MediaType? = contentType
override fun contentLength(): Long = contentLength
override fun writeTo(sink: BufferedSink) {
val buffer = Buffer()
var uploaded: Long = 0
val inputStream = contentResolver.openInputStream(this@asRequestBody)
?: throw FileNotFoundException("Unavailable ContentProvider")
inputStream.source().use { source ->
while (true) {
val read = source.read(buffer, DEFAULT_CHUNK_SIZE)
if (read == -1L) {
break
}
sink.write(buffer, read)
uploaded += read
uploadListener?.let { if (contentLength > 0L) it.onProgressUpdate((100L * uploaded / contentLength).toInt()) }
}
uploadListener?.onProgressUpdate(100)
}
}
}
}

View File

@ -8,7 +8,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/styles.xml"
line="135"
line="134"
column="42"/>
</issue>
@ -19,7 +19,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/styles.xml"
line="136"
line="135"
column="43"/>
</issue>

View File

@ -61,6 +61,7 @@ moshi = "1.15.1"
moshix = "0.27.1"
networkresult-calladapter = "1.0.0"
okhttp = "4.12.0"
okio = "3.9.0"
quadrant = "1.9.1"
retrofit = "2.11.0"
robolectric = "4.12.2"
@ -203,6 +204,7 @@ networkresult-calladapter = { module = "at.connyduck:networkresult-calladapter",
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }