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:
parent
8877482abc
commit
f3354d1aae
|
@ -151,6 +151,7 @@ dependencies {
|
|||
implementation(libs.bundles.retrofit)
|
||||
|
||||
implementation(libs.bundles.okhttp)
|
||||
implementation(libs.okio)
|
||||
|
||||
implementation(libs.conscrypt.android)
|
||||
|
||||
|
|
|
@ -105,11 +105,11 @@
|
|||
<issue
|
||||
id="StringFormatInvalid"
|
||||
message="Format string '`app_name`' is not a valid format string so it should not be passed to `String.format`"
|
||||
errorLine1=" val filename = "%s_%s_%s.%s".format("
|
||||
errorLine1=" val filename = "%s_%d_%s.%s".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"
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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" }
|
||||
|
|
Loading…
Reference in New Issue