diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b457274a7..3df8ce3fa 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -151,6 +151,7 @@ dependencies {
implementation(libs.bundles.retrofit)
implementation(libs.bundles.okhttp)
+ implementation(libs.okio)
implementation(libs.conscrypt.android)
diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
index 0384fed8c..debef57f9 100644
--- a/app/lint-baseline.xml
+++ b/app/lint-baseline.xml
@@ -105,11 +105,11 @@
+ 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,
+ )
+ }
}
diff --git a/app/src/main/java/app/pachli/components/compose/MediaUploader.kt b/app/src/main/java/app/pachli/components/compose/MediaUploader.kt
index 920987ea6..6a53d33c0 100644
--- a/app/src/main/java/app/pachli/components/compose/MediaUploader.kt
+++ b/app/src/main/java/app/pachli/components/compose/MediaUploader.kt
@@ -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))
diff --git a/app/src/main/java/app/pachli/components/drafts/DraftHelper.kt b/app/src/main/java/app/pachli/components/drafts/DraftHelper.kt
index 126e720c2..d2b46ae3f 100644
--- a/app/src/main/java/app/pachli/components/drafts/DraftHelper.kt
+++ b/app/src/main/java/app/pachli/components/drafts/DraftHelper.kt
@@ -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")
diff --git a/app/src/main/java/app/pachli/network/ProgressRequestBody.kt b/app/src/main/java/app/pachli/network/ProgressRequestBody.kt
deleted file mode 100644
index e42fd4e57..000000000
--- a/app/src/main/java/app/pachli/network/ProgressRequestBody.kt
+++ /dev/null
@@ -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 .
- */
-
-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
- }
-}
diff --git a/app/src/main/java/app/pachli/util/CompositeWithOpaqueBackground.kt b/app/src/main/java/app/pachli/util/CompositeWithOpaqueBackground.kt
index 8f5dbc33b..5c13b5b6e 100644
--- a/app/src/main/java/app/pachli/util/CompositeWithOpaqueBackground.kt
+++ b/app/src/main/java/app/pachli/util/CompositeWithOpaqueBackground.kt
@@ -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 {
diff --git a/app/src/main/java/app/pachli/util/IOUtils.kt b/app/src/main/java/app/pachli/util/IOUtils.kt
deleted file mode 100644
index ac4fc48c2..000000000
--- a/app/src/main/java/app/pachli/util/IOUtils.kt
+++ /dev/null
@@ -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 .
- */
-
-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
-}
diff --git a/app/src/main/java/app/pachli/util/MediaUtils.kt b/app/src/main/java/app/pachli/util/MediaUtils.kt
index 928ac34e2..6a8e5321e 100644
--- a/app/src/main/java/app/pachli/util/MediaUtils.kt
+++ b/app/src/main/java/app/pachli/util/MediaUtils.kt
@@ -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"
}
diff --git a/app/src/main/java/app/pachli/util/UriExtensions.kt b/app/src/main/java/app/pachli/util/UriExtensions.kt
new file mode 100644
index 000000000..dd6d5efde
--- /dev/null
+++ b/app/src/main/java/app/pachli/util/UriExtensions.kt
@@ -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 .
+ */
+
+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)
+ }
+ }
+ }
+}
diff --git a/core/designsystem/lint-baseline.xml b/core/designsystem/lint-baseline.xml
index d61c63df9..7fb106162 100644
--- a/core/designsystem/lint-baseline.xml
+++ b/core/designsystem/lint-baseline.xml
@@ -8,7 +8,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -19,7 +19,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c2edda8a7..8ff7ef49a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }