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.retrofit)
|
||||||
|
|
||||||
implementation(libs.bundles.okhttp)
|
implementation(libs.bundles.okhttp)
|
||||||
|
implementation(libs.okio)
|
||||||
|
|
||||||
implementation(libs.conscrypt.android)
|
implementation(libs.conscrypt.android)
|
||||||
|
|
||||||
|
|
|
@ -105,11 +105,11 @@
|
||||||
<issue
|
<issue
|
||||||
id="StringFormatInvalid"
|
id="StringFormatInvalid"
|
||||||
message="Format string '`app_name`' is not a valid format string so it should not be passed to `String.format`"
|
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=" ^">
|
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"
|
||||||
|
|
|
@ -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()
|
||||||
options.inJustDecodeBounds = true
|
val inputStream = contentResolver.openInputStream(uri)
|
||||||
decodeBoundsInputStream.use { BitmapFactory.decodeStream(it, null, options) }
|
?: throw FileNotFoundException("openInputStream returned null")
|
||||||
|
inputStream.use { input ->
|
||||||
|
options.inJustDecodeBounds = true
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,22 +167,20 @@ 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",
|
file,
|
||||||
file,
|
)
|
||||||
)
|
mediaSize = getMediaSize(contentResolver, uri)
|
||||||
mediaSize = getMediaSize(contentResolver, uri)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ContentResolver.SCHEME_FILE -> {
|
ContentResolver.SCHEME_FILE -> {
|
||||||
|
@ -195,17 +193,16 @@ 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(
|
|
||||||
context,
|
|
||||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
|
||||||
file,
|
|
||||||
)
|
|
||||||
mediaSize = getMediaSize(contentResolver, uri)
|
|
||||||
}
|
}
|
||||||
|
uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
mediaSize = getMediaSize(contentResolver, uri)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Timber.w("Unknown uri scheme %s", uri)
|
Timber.w("Unknown uri scheme %s", uri)
|
||||||
|
@ -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))
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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.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 {
|
||||||
|
|
|
@ -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 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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
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>
|
||||||
|
|
||||||
|
|
|
@ -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" }
|
||||||
|
|
Loading…
Reference in New Issue