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.retrofit)
implementation(libs.bundles.okhttp) implementation(libs.bundles.okhttp)
implementation(libs.okio)
implementation(libs.conscrypt.android) implementation(libs.conscrypt.android)

View File

@ -105,11 +105,11 @@
<issue <issue
id="StringFormatInvalid" id="StringFormatInvalid"
message="Format string &apos;`app_name`&apos; is not a valid format string so it should not be passed to `String.format`" 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=" ^"> errorLine2=" ^">
<location <location
file="src/main/java/app/pachli/components/compose/MediaUploader.kt" file="src/main/java/app/pachli/components/compose/MediaUploader.kt"
line="271" line="268"
column="28"/> column="28"/>
<location <location
file="${:core:activity*buildDir}/generated/res/resValues/orangeFdroid/debug/values/gradleResValues.xml" 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.Bitmap.CompressFormat
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import app.pachli.util.calculateInSampleSize import app.pachli.util.calculateInSampleSize
import app.pachli.util.getImageOrientation
import app.pachli.util.reorientBitmap import app.pachli.util.reorientBitmap
import java.io.File import java.io.File
import java.io.FileNotFoundException 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 sizeLimit the maximum number of bytes the output image is allowed to have
* @param contentResolver to resolve the specified input uri * @param contentResolver to resolve the specified input uri
* @param tempFile the file where the result will be stored * @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( fun downsizeImage(
uri: Uri, uri: Uri,
sizeLimit: Long, sizeLimit: Long,
contentResolver: ContentResolver, contentResolver: ContentResolver,
tempFile: File, tempFile: File,
): Boolean { ) {
val decodeBoundsInputStream = try {
contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {
return false
}
// Initially, just get the image dimensions. // Initially, just get the image dimensions.
val options = BitmapFactory.Options() val options = BitmapFactory.Options()
val inputStream = contentResolver.openInputStream(uri)
?: throw FileNotFoundException("openInputStream returned null")
inputStream.use { input ->
options.inJustDecodeBounds = true options.inJustDecodeBounds = true
decodeBoundsInputStream.use { BitmapFactory.decodeStream(it, null, options) } BitmapFactory.decodeStream(input, null, options)
}
// Get EXIF data, for orientation info. // Get EXIF data, for orientation info.
val orientation = getImageOrientation(uri, contentResolver) val orientation = getImageOrientation(uri, contentResolver)
/* Unfortunately, there isn't a determined worst case compression ratio for image /* 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 * 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 * 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. */ * sure it gets downsized to below the limit. */
var scaledImageSize = 1024 var scaledImageSize = 1024
do { do {
val outputStream = try { val outputStream = FileOutputStream(tempFile)
FileOutputStream(tempFile) val decodeBitmapInputStream = contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) { ?: throw FileNotFoundException("openInputStream returned null")
return false
}
val decodeBitmapInputStream = try {
contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {
return false
}
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize) options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize)
options.inJustDecodeBounds = false options.inJustDecodeBounds = false
val scaledBitmap = decodeBitmapInputStream.use { val scaledBitmap = decodeBitmapInputStream.use {
BitmapFactory.decodeStream(it, null, options) BitmapFactory.decodeStream(it, null, options)
} ?: return false } ?: return
val reorientedBitmap = reorientBitmap(scaledBitmap, orientation) val reorientedBitmap = reorientBitmap(scaledBitmap, orientation)
if (reorientedBitmap == null) { if (reorientedBitmap == null) {
scaledBitmap.recycle() scaledBitmap.recycle()
return false return
} }
/* Retain transparency if there is any by encoding as png */ /* Retain transparency if there is any by encoding as png */
val format: CompressFormat = if (!reorientedBitmap.hasAlpha()) { val format: CompressFormat = if (!reorientedBitmap.hasAlpha()) {
@ -90,6 +84,20 @@ fun downsizeImage(
reorientedBitmap.recycle() reorientedBitmap.recycle()
scaledImageSize /= 2 scaledImageSize /= 2
} while (tempFile.length() > sizeLimit) } 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 package app.pachli.components.compose
import android.annotation.SuppressLint
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.media.MediaMetadataRetriever 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.data.model.InstanceInfo
import app.pachli.core.network.model.MediaUploadApi import app.pachli.core.network.model.MediaUploadApi
import app.pachli.core.ui.extensions.getErrorString import app.pachli.core.ui.extensions.getErrorString
import app.pachli.network.ProgressRequestBody
import app.pachli.util.MEDIA_SIZE_UNKNOWN import app.pachli.util.MEDIA_SIZE_UNKNOWN
import app.pachli.util.asRequestBody
import app.pachli.util.getImageSquarePixels import app.pachli.util.getImageSquarePixels
import app.pachli.util.getMediaSize import app.pachli.util.getMediaSize
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -60,6 +57,9 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okio.buffer
import okio.sink
import okio.source
import retrofit2.HttpException import retrofit2.HttpException
import timber.log.Timber import timber.log.Timber
@ -167,15 +167,14 @@ class MediaUploader @Inject constructor(
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
contentResolver.openInputStream(inUri).use { input -> contentResolver.openInputStream(inUri)?.source()?.buffer().use { input ->
if (input == null) { if (input == null) {
Timber.w("Media input is null") Timber.w("Media input is null")
uri = inUri uri = inUri
return@use return@use
} }
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
FileOutputStream(file.absoluteFile).use { out -> file.absoluteFile.sink().buffer().use { it.writeAll(input) }
input.copyTo(out)
uri = FileProvider.getUriForFile( uri = FileProvider.getUriForFile(
context, context,
BuildConfig.APPLICATION_ID + ".fileprovider", BuildConfig.APPLICATION_ID + ".fileprovider",
@ -184,7 +183,6 @@ class MediaUploader @Inject constructor(
mediaSize = getMediaSize(contentResolver, uri) mediaSize = getMediaSize(contentResolver, uri)
} }
} }
}
ContentResolver.SCHEME_FILE -> { ContentResolver.SCHEME_FILE -> {
val path = uri.path val path = uri.path
if (path == null) { if (path == null) {
@ -195,10 +193,10 @@ class MediaUploader @Inject constructor(
val suffix = inputFile.name.substringAfterLast('.', "tmp") val suffix = inputFile.name.substringAfterLast('.', "tmp")
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix) mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir) val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
val input = FileInputStream(inputFile)
FileOutputStream(file.absoluteFile).use { out -> inputFile.source().buffer().use { input ->
input.copyTo(out) file.absoluteFile.sink().buffer().use { it.writeAll(input) }
}
uri = FileProvider.getUriForFile( uri = FileProvider.getUriForFile(
context, context,
BuildConfig.APPLICATION_ID + ".fileprovider", BuildConfig.APPLICATION_ID + ".fileprovider",
@ -206,7 +204,6 @@ class MediaUploader @Inject constructor(
) )
mediaSize = getMediaSize(contentResolver, uri) mediaSize = getMediaSize(contentResolver, uri)
} }
}
else -> { else -> {
Timber.w("Unknown uri scheme %s", uri) Timber.w("Unknown uri scheme %s", uri)
throw CouldNotOpenFileException() throw CouldNotOpenFileException()
@ -268,24 +265,20 @@ class MediaUploader @Inject constructor(
} }
val map = MimeTypeMap.getSingleton() val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType) 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), context.getString(R.string.app_name),
System.currentTimeMillis().toString(), System.currentTimeMillis(),
randomAlphanumericString(10), randomAlphanumericString(10),
fileExtension, fileExtension,
) )
// `stream` is closed in ProgressRequestBody.writeTo
@SuppressLint("recycle")
val stream = contentResolver.openInputStream(media.uri)
if (mimeType == null) mimeType = "multipart/form-data" if (mimeType == null) mimeType = "multipart/form-data"
var lastProgress = -1 var lastProgress = -1
val fileBody = ProgressRequestBody( val fileBody = media.uri.asRequestBody(
stream!!, contentResolver,
requireNotNull(mimeType.toMediaTypeOrNull()) { "Invalid Content Type" },
media.mediaSize, media.mediaSize,
mimeType.toMediaTypeOrNull()!!,
) { percentage -> ) { percentage ->
if (percentage != lastProgress) { if (percentage != lastProgress) {
trySend(UploadEvent.ProgressEvent(percentage)) trySend(UploadEvent.ProgressEvent(percentage))

View File

@ -182,15 +182,10 @@ class DraftHelper @Inject constructor(
// saving redrafted media // saving redrafted media
try { try {
val request = Request.Builder().url(toString()).build() val request = Request.Builder().url(toString()).build()
val response = okHttpClient.newCall(request).execute() val response = okHttpClient.newCall(request).execute()
val sink = file.sink().buffer() response.body?.source()?.buffer?.use { input ->
file.sink().buffer().use { it.writeAll(input) }
response.body?.source()?.use { input ->
sink.use { output ->
output.writeAll(input)
}
} }
} catch (ex: IOException) { } catch (ex: IOException) {
Timber.w(ex, "failed to save media") 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.Paint
import android.graphics.Shader import android.graphics.Shader
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import com.bumptech.glide.load.Key
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import com.bumptech.glide.util.Util
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.Charset
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Objects
/** /**
* Set an opaque background behind the non-transparent areas of a bitmap. * 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 return false
} }
override fun hashCode() = Util.hashCode(ID.hashCode(), backgroundColor.hashCode()) override fun hashCode() = Objects.hash(ID, backgroundColor)
override fun updateDiskCacheKey(messageDigest: MessageDigest) { override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(ID_BYTES) 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( override fun transform(
@ -109,7 +110,7 @@ class CompositeWithOpaqueBackground(@ColorInt val backgroundColor: Int) : Bitmap
companion object { companion object {
private val ID = CompositeWithOpaqueBackground::class.qualifiedName!! 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 */ /** Paint with a color filter that converts 8bpp alpha images to a 1bpp mask */
private val EXTRACT_MASK_PAINT = Paint().apply { 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 android.provider.OpenableColumns
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import timber.log.Timber
/** /**
* Helper methods for obtaining and resizing media files * 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} * @return the size of the media in bytes or {@link MediaUtils#MEDIA_SIZE_UNKNOWN}
*/ */
fun getMediaSize(contentResolver: ContentResolver, uri: Uri?): Long { fun getMediaSize(contentResolver: ContentResolver, uri: Uri?): Long {
if (uri == null) { uri ?: return MEDIA_SIZE_UNKNOWN
return MEDIA_SIZE_UNKNOWN
}
var mediaSize = MEDIA_SIZE_UNKNOWN var mediaSize = MEDIA_SIZE_UNKNOWN
val cursor: Cursor? val cursor: Cursor?
@ -66,14 +62,14 @@ fun getMediaSize(contentResolver: ContentResolver, uri: Uri?): Long {
} }
@Throws(FileNotFoundException::class) @Throws(FileNotFoundException::class)
fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Long { fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Int {
val options = BitmapFactory.Options() val options = BitmapFactory.Options()
contentResolver.openInputStream(uri).use { input -> contentResolver.openInputStream(uri).use { input ->
options.inJustDecodeBounds = true options.inJustDecodeBounds = true
BitmapFactory.decodeStream(input, null, options) 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 { fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
@ -119,9 +115,7 @@ fun reorientBitmap(bitmap: Bitmap?, orientation: Int): Bitmap? {
else -> return bitmap else -> return bitmap
} }
if (bitmap == null) { bitmap ?: return null
return null
}
return try { return try {
val result = Bitmap.createBitmap( 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 { fun getTemporaryMediaFilename(extension: String): String {
return "${MEDIA_TEMP_PREFIX}_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.$extension" 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=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location <location
file="src/main/res/values/styles.xml" file="src/main/res/values/styles.xml"
line="135" line="134"
column="42"/> column="42"/>
</issue> </issue>
@ -19,7 +19,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location <location
file="src/main/res/values/styles.xml" file="src/main/res/values/styles.xml"
line="136" line="135"
column="43"/> column="43"/>
</issue> </issue>

View File

@ -61,6 +61,7 @@ moshi = "1.15.1"
moshix = "0.27.1" moshix = "0.27.1"
networkresult-calladapter = "1.0.0" networkresult-calladapter = "1.0.0"
okhttp = "4.12.0" okhttp = "4.12.0"
okio = "3.9.0"
quadrant = "1.9.1" quadrant = "1.9.1"
retrofit = "2.11.0" retrofit = "2.11.0"
robolectric = "4.12.2" 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-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", 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" } 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-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }