From 1c0e0b16187f4868a201503fd8ced3ef1c39b101 Mon Sep 17 00:00:00 2001 From: eastshine2741 Date: Sun, 28 Jul 2024 13:42:23 +0900 Subject: [PATCH 1/6] =?UTF-8?q?zelory=20Compressor=20=EB=B3=B5=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../siksha2/utils/compressor/Compression.kt | 9 ++ .../siksha2/utils/compressor/Compressor.kt | 31 +++++ .../utils/compressor/CompressorUtil.kt | 108 ++++++++++++++++++ .../siksha2/utils/compressor/Constraint.kt | 9 ++ .../utils/compressor/DefaultConstraint.kt | 42 +++++++ .../utils/compressor/FormatConstraint.kt | 25 ++++ .../utils/compressor/ResolutionConstraint.kt | 33 ++++++ .../utils/compressor/SizeConstraint.kt | 32 ++++++ 8 files changed, 289 insertions(+) create mode 100644 app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compression.kt create mode 100644 app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compressor.kt create mode 100644 app/src/main/java/com/wafflestudio/siksha2/utils/compressor/CompressorUtil.kt create mode 100644 app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Constraint.kt create mode 100644 app/src/main/java/com/wafflestudio/siksha2/utils/compressor/DefaultConstraint.kt create mode 100644 app/src/main/java/com/wafflestudio/siksha2/utils/compressor/FormatConstraint.kt create mode 100644 app/src/main/java/com/wafflestudio/siksha2/utils/compressor/ResolutionConstraint.kt create mode 100644 app/src/main/java/com/wafflestudio/siksha2/utils/compressor/SizeConstraint.kt diff --git a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compression.kt b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compression.kt new file mode 100644 index 000000000..731cddef1 --- /dev/null +++ b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compression.kt @@ -0,0 +1,9 @@ +package com.wafflestudio.siksha2.utils.compressor + +class Compression { + internal val constraints: MutableList = mutableListOf() + + fun constraint(constraint: Constraint) { + constraints.add(constraint) + } +} diff --git a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compressor.kt b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compressor.kt new file mode 100644 index 000000000..494928db3 --- /dev/null +++ b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compressor.kt @@ -0,0 +1,31 @@ +package com.wafflestudio.siksha2.utils.compressor + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.coroutines.CoroutineContext + +/** + * Created on : January 22, 2020 + * Author : zetbaitsu + * Name : Zetra + * GitHub : https://github.com/zetbaitsu + */ +object Compressor { + suspend fun compress( + context: Context, + imageFile: File, + coroutineContext: CoroutineContext = Dispatchers.IO, + compressionPatch: Compression.() -> Unit = { default() } + ) = withContext(coroutineContext) { + val compression = Compression().apply(compressionPatch) + var result = copyToCache(context, imageFile) + compression.constraints.forEach { constraint -> + while (constraint.isSatisfied(result).not()) { + result = constraint.satisfy(result) + } + } + return@withContext result + } +} diff --git a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/CompressorUtil.kt b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/CompressorUtil.kt new file mode 100644 index 000000000..6a02804c4 --- /dev/null +++ b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/CompressorUtil.kt @@ -0,0 +1,108 @@ +package com.wafflestudio.siksha2.utils.compressor + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.media.ExifInterface +import java.io.File +import java.io.FileOutputStream + +/** + * Created on : January 24, 2020 + * Author : zetbaitsu + * Name : Zetra + * GitHub : https://github.com/zetbaitsu + */ +private val separator = File.separator + +private fun cachePath(context: Context) = "${context.cacheDir.path}${separator}compressor$separator" + +fun File.compressFormat() = when (extension.toLowerCase()) { + "png" -> Bitmap.CompressFormat.PNG + "webp" -> Bitmap.CompressFormat.WEBP + else -> Bitmap.CompressFormat.JPEG +} + +fun Bitmap.CompressFormat.extension() = when (this) { + Bitmap.CompressFormat.PNG -> "png" + Bitmap.CompressFormat.WEBP -> "webp" + else -> "jpg" +} + +fun loadBitmap(imageFile: File) = BitmapFactory.decodeFile(imageFile.absolutePath).run { + determineImageRotation(imageFile, this) +} + +fun decodeSampledBitmapFromFile(imageFile: File, reqWidth: Int, reqHeight: Int): Bitmap { + return BitmapFactory.Options().run { + inJustDecodeBounds = true + BitmapFactory.decodeFile(imageFile.absolutePath, this) + + inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) + + inJustDecodeBounds = false + BitmapFactory.decodeFile(imageFile.absolutePath, this) + } +} + +fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { + // Raw height and width of image + val (height: Int, width: Int) = options.run { outHeight to outWidth } + var inSampleSize = 1 + + if (height > reqHeight || width > reqWidth) { + + val halfHeight: Int = height / 2 + val halfWidth: Int = width / 2 + + // Calculate the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 + } + } + + return inSampleSize +} + +fun determineImageRotation(imageFile: File, bitmap: Bitmap): Bitmap { + val exif = ExifInterface(imageFile.absolutePath) + val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0) + val matrix = Matrix() + when (orientation) { + 6 -> matrix.postRotate(90f) + 3 -> matrix.postRotate(180f) + 8 -> matrix.postRotate(270f) + } + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) +} + +internal fun copyToCache(context: Context, imageFile: File): File { + return imageFile.copyTo(File("${cachePath(context)}${imageFile.name}"), true) +} + +fun overWrite(imageFile: File, bitmap: Bitmap, format: Bitmap.CompressFormat = imageFile.compressFormat(), quality: Int = 100): File { + val result = if (format == imageFile.compressFormat()) { + imageFile + } else { + File("${imageFile.absolutePath.substringBeforeLast(".")}.${format.extension()}") + } + imageFile.delete() + saveBitmap(bitmap, result, format, quality) + return result +} + +fun saveBitmap(bitmap: Bitmap, destination: File, format: Bitmap.CompressFormat = destination.compressFormat(), quality: Int = 100) { + destination.parentFile?.mkdirs() + var fileOutputStream: FileOutputStream? = null + try { + fileOutputStream = FileOutputStream(destination.absolutePath) + bitmap.compress(format, quality, fileOutputStream) + } finally { + fileOutputStream?.run { + flush() + close() + } + } +} diff --git a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Constraint.kt b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Constraint.kt new file mode 100644 index 000000000..7928768ad --- /dev/null +++ b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Constraint.kt @@ -0,0 +1,9 @@ +package com.wafflestudio.siksha2.utils.compressor + +import java.io.File + +interface Constraint { + fun isSatisfied(imageFile: File): Boolean + + fun satisfy(imageFile: File): File +} diff --git a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/DefaultConstraint.kt b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/DefaultConstraint.kt new file mode 100644 index 000000000..ebd4b0bf2 --- /dev/null +++ b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/DefaultConstraint.kt @@ -0,0 +1,42 @@ +package com.wafflestudio.siksha2.utils.compressor + +import android.graphics.Bitmap +import java.io.File + +/** + * Created on : January 25, 2020 + * Author : zetbaitsu + * Name : Zetra + * GitHub : https://github.com/zetbaitsu + */ +class DefaultConstraint( + private val width: Int = 612, + private val height: Int = 816, + private val format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, + private val quality: Int = 80 +) : Constraint { + private var isResolved = false + + override fun isSatisfied(imageFile: File): Boolean { + return isResolved + } + + override fun satisfy(imageFile: File): File { + val result = decodeSampledBitmapFromFile(imageFile, width, height).run { + determineImageRotation(imageFile, this).run { + overWrite(imageFile, this, format, quality) + } + } + isResolved = true + return result + } +} + +fun Compression.default( + width: Int = 612, + height: Int = 816, + format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, + quality: Int = 80 +) { + constraint(DefaultConstraint(width, height, format, quality)) +} diff --git a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/FormatConstraint.kt b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/FormatConstraint.kt new file mode 100644 index 000000000..c0004b6cc --- /dev/null +++ b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/FormatConstraint.kt @@ -0,0 +1,25 @@ +package com.wafflestudio.siksha2.utils.compressor + +import android.graphics.Bitmap +import java.io.File + +/** + * Created on : January 24, 2020 + * Author : zetbaitsu + * Name : Zetra + * GitHub : https://github.com/zetbaitsu + */ +class FormatConstraint(private val format: Bitmap.CompressFormat) : Constraint { + + override fun isSatisfied(imageFile: File): Boolean { + return format == imageFile.compressFormat() + } + + override fun satisfy(imageFile: File): File { + return overWrite(imageFile, loadBitmap(imageFile), format) + } +} + +fun Compression.format(format: Bitmap.CompressFormat) { + constraint(FormatConstraint(format)) +} diff --git a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/ResolutionConstraint.kt b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/ResolutionConstraint.kt new file mode 100644 index 000000000..93af9228d --- /dev/null +++ b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/ResolutionConstraint.kt @@ -0,0 +1,33 @@ +package com.wafflestudio.siksha2.utils.compressor + +import android.graphics.BitmapFactory +import java.io.File + +/** + * Created on : January 24, 2020 + * Author : zetbaitsu + * Name : Zetra + * GitHub : https://github.com/zetbaitsu + */ +class ResolutionConstraint(private val width: Int, private val height: Int) : Constraint { + + override fun isSatisfied(imageFile: File): Boolean { + return BitmapFactory.Options().run { + inJustDecodeBounds = true + BitmapFactory.decodeFile(imageFile.absolutePath, this) + calculateInSampleSize(this, width, height) <= 1 + } + } + + override fun satisfy(imageFile: File): File { + return decodeSampledBitmapFromFile(imageFile, width, height).run { + determineImageRotation(imageFile, this).run { + overWrite(imageFile, this) + } + } + } +} + +fun Compression.resolution(width: Int, height: Int) { + constraint(ResolutionConstraint(width, height)) +} diff --git a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/SizeConstraint.kt b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/SizeConstraint.kt new file mode 100644 index 000000000..736dd2328 --- /dev/null +++ b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/SizeConstraint.kt @@ -0,0 +1,32 @@ +package com.wafflestudio.siksha2.utils.compressor + +import java.io.File + +/** + * Created on : January 24, 2020 + * Author : zetbaitsu + * Name : Zetra + * GitHub : https://github.com/zetbaitsu + */ +class SizeConstraint( + private val maxFileSize: Long, + private val stepSize: Int = 10, + private val maxIteration: Int = 10, + private val minQuality: Int = 10 +) : Constraint { + private var iteration: Int = 0 + + override fun isSatisfied(imageFile: File): Boolean { + return imageFile.length() <= maxFileSize || iteration >= maxIteration + } + + override fun satisfy(imageFile: File): File { + iteration++ + val quality = (100 - iteration * stepSize).takeIf { it >= minQuality } ?: minQuality + return overWrite(imageFile, loadBitmap(imageFile), quality = quality) + } +} + +fun Compression.size(maxFileSize: Long, stepSize: Int = 10, maxIteration: Int = 10) { + constraint(SizeConstraint(maxFileSize, stepSize, maxIteration)) +} From ac8b8672fc67f0218009112292b08d271bffd8dd Mon Sep 17 00:00:00 2001 From: eastshine2741 Date: Sun, 28 Jul 2024 14:05:48 +0900 Subject: [PATCH 2/6] =?UTF-8?q?Compressor.compress=EA=B0=80=20file=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20uri=EB=A5=BC=20=EB=B0=9B=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../siksha2/utils/compressor/Compressor.kt | 6 +- .../utils/compressor/CompressorUtil.kt | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compressor.kt b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compressor.kt index 494928db3..5316ee936 100644 --- a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compressor.kt +++ b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/Compressor.kt @@ -1,9 +1,9 @@ package com.wafflestudio.siksha2.utils.compressor import android.content.Context +import android.net.Uri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.io.File import kotlin.coroutines.CoroutineContext /** @@ -15,12 +15,12 @@ import kotlin.coroutines.CoroutineContext object Compressor { suspend fun compress( context: Context, - imageFile: File, + imageUri: Uri, coroutineContext: CoroutineContext = Dispatchers.IO, compressionPatch: Compression.() -> Unit = { default() } ) = withContext(coroutineContext) { val compression = Compression().apply(compressionPatch) - var result = copyToCache(context, imageFile) + var result = copyToCache(context, imageUri) compression.constraints.forEach { constraint -> while (constraint.isSatisfied(result).not()) { result = constraint.satisfy(result) diff --git a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/CompressorUtil.kt b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/CompressorUtil.kt index 6a02804c4..86b37f2cd 100644 --- a/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/CompressorUtil.kt +++ b/app/src/main/java/com/wafflestudio/siksha2/utils/compressor/CompressorUtil.kt @@ -5,8 +5,13 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import android.media.ExifInterface +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.OpenableColumns import java.io.File import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.* /** * Created on : January 24, 2020 @@ -82,6 +87,58 @@ internal fun copyToCache(context: Context, imageFile: File): File { return imageFile.copyTo(File("${cachePath(context)}${imageFile.name}"), true) } +fun copyToCache(context: Context, srcFileUri: Uri): File { + val cacheFile = File("${cachePath(context)}${getFileName(context, srcFileUri)}") + cacheFile.parentFile.mkdirs() + if (cacheFile.exists()) { + cacheFile.delete() + } + cacheFile.createNewFile() + cacheFile.deleteOnExit() + val fd = context.contentResolver.openFileDescriptor(srcFileUri, "r") + val inputStream = ParcelFileDescriptor.AutoCloseInputStream(fd) + val outputStream = FileOutputStream(cacheFile) + inputStream.use { + outputStream.use { + inputStream.copyTo(outputStream) + } + } + return cacheFile +} + +fun getFileName(context: Context, uri: Uri): String { + val resolver = context.contentResolver + val cursor = resolver.query( + uri, arrayOf( + OpenableColumns.DISPLAY_NAME + ), null, null, null + ) + cursor.use { + val nameIndex = it!!.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (it.moveToFirst()) { + return it.getString(nameIndex) + } else { + val prefix = "IMG_" + SimpleDateFormat( + "yyyyMMdd_", + Locale.getDefault() + ).format(Date()) + System.nanoTime() + return when (val fileMimeType = resolver.getType(uri)) { + "image/jpg", "image/jpeg" -> { + "$prefix.jpeg" + } + + "image/png" -> { + "$prefix.png" + } + + else -> { + throw IllegalStateException("$fileMimeType fallback display name not supported") + } + } + } + } +} + fun overWrite(imageFile: File, bitmap: Bitmap, format: Bitmap.CompressFormat = imageFile.compressFormat(), quality: Int = 100): File { val result = if (format == imageFile.compressFormat()) { imageFile From 12e2d029f599e02cb4c2f98c66c85c2641b7bcdb Mon Sep 17 00:00:00 2001 From: eastshine2741 Date: Sun, 28 Jul 2024 14:06:22 +0900 Subject: [PATCH 3/6] =?UTF-8?q?ImageUtil=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wafflestudio/siksha2/utils/ImageUtil.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 app/src/main/java/com/wafflestudio/siksha2/utils/ImageUtil.kt diff --git a/app/src/main/java/com/wafflestudio/siksha2/utils/ImageUtil.kt b/app/src/main/java/com/wafflestudio/siksha2/utils/ImageUtil.kt new file mode 100644 index 000000000..a3217ae01 --- /dev/null +++ b/app/src/main/java/com/wafflestudio/siksha2/utils/ImageUtil.kt @@ -0,0 +1,20 @@ +package com.wafflestudio.siksha2.utils + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import com.wafflestudio.siksha2.utils.compressor.Compressor +import com.wafflestudio.siksha2.utils.compressor.format +import com.wafflestudio.siksha2.utils.compressor.resolution +import com.wafflestudio.siksha2.utils.compressor.size +import java.io.File + +object ImageUtil { + suspend fun getCompressedImage(context: Context, imageUri: Uri): File { + return Compressor.compress(context, imageUri) { + resolution(300, 300) + size(100000) + format(Bitmap.CompressFormat.JPEG) + } + } +} From 1acaf61c7f60f16941349dff2fd7b4a1e49e9572 Mon Sep 17 00:00:00 2001 From: eastshine2741 Date: Sun, 28 Jul 2024 14:09:07 +0900 Subject: [PATCH 4/6] =?UTF-8?q?MenuDetailViewModel=EC=97=90=20ImageUtil.ge?= =?UTF-8?q?tCompressedImage()=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/menuDetail/MenuDetailViewModel.kt | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/MenuDetailViewModel.kt b/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/MenuDetailViewModel.kt index 721a2fada..e5fb4e649 100644 --- a/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/MenuDetailViewModel.kt +++ b/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/MenuDetailViewModel.kt @@ -1,7 +1,6 @@ package com.wafflestudio.siksha2.ui.menuDetail import android.content.Context -import android.graphics.Bitmap import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -11,19 +10,14 @@ import androidx.paging.PagingData import com.wafflestudio.siksha2.models.Menu import com.wafflestudio.siksha2.models.Review import com.wafflestudio.siksha2.repositories.MenuRepository -import com.wafflestudio.siksha2.utils.PathUtil +import com.wafflestudio.siksha2.utils.ImageUtil import com.wafflestudio.siksha2.utils.showToast import dagger.hilt.android.lifecycle.HiltViewModel -import id.zelory.compressor.Compressor -import id.zelory.compressor.constraint.format -import id.zelory.compressor.constraint.resolution -import id.zelory.compressor.constraint.size import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody -import java.io.File import java.io.IOException import javax.inject.Inject @@ -167,7 +161,10 @@ class MenuDetailViewModel @Inject constructor( context.showToast("이미지 압축 중입니다.") _leaveReviewState.value = ReviewState.COMPRESSING val imageList = _imageUriList.value?.map { - getCompressedImage(context, it) + ImageUtil.getCompressedImage(context, it) + }?.map { file -> + val requestBody = file.asRequestBody("image/jpeg".toMediaTypeOrNull()) + MultipartBody.Part.createFormData("images", file.name, requestBody) } val commentBody = MultipartBody.Part.createFormData("comment", comment) imageList?.let { @@ -178,18 +175,6 @@ class MenuDetailViewModel @Inject constructor( } } - private suspend fun getCompressedImage(context: Context, uri: Uri): MultipartBody.Part { - val path = PathUtil.getPath(context, uri) - var file = File(path) - file = Compressor.compress(context, file) { - resolution(300, 300) - size(100000) - format(Bitmap.CompressFormat.JPEG) - } - val requestBody = file.asRequestBody("image/jpeg".toMediaTypeOrNull()) - return MultipartBody.Part.createFormData("images", file.name, requestBody) - } - enum class State { LOADING, SUCCESS, From 75ee0bf0128dc44af24c10970c20b10272236e69 Mon Sep 17 00:00:00 2001 From: eastshine2741 Date: Sun, 28 Jul 2024 14:51:20 +0900 Subject: [PATCH 5/6] =?UTF-8?q?zelory=20compressor=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f68a9f68a..314b697e8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -170,9 +170,6 @@ dependencies { // Glide implementation("com.github.bumptech.glide:glide:4.15.1") - // Image Compression - implementation("id.zelory:compressor:3.0.1") - testImplementation("junit:junit:4.+") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") From 1883fc71896f8ad14b31492eac3f491015d0bc5f Mon Sep 17 00:00:00 2001 From: eastshine2741 Date: Sun, 28 Jul 2024 15:20:22 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EB=A9=B4=20=EB=AF=B8=EB=A6=AC=20=EC=95=95?= =?UTF-8?q?=EC=B6=95=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/menuDetail/LeaveReviewFragment.kt | 109 ++++++++--------- .../ui/menuDetail/MenuDetailViewModel.kt | 110 +++++++++++++----- .../main/res/layout/fragment_leave_review.xml | 59 +++++++--- 3 files changed, 173 insertions(+), 105 deletions(-) diff --git a/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/LeaveReviewFragment.kt b/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/LeaveReviewFragment.kt index a2e859bdc..3d0d95544 100644 --- a/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/LeaveReviewFragment.kt +++ b/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/LeaveReviewFragment.kt @@ -1,18 +1,14 @@ package com.wafflestudio.siksha2.ui.menuDetail -import android.Manifest -import android.app.Activity.RESULT_OK -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle -import android.provider.MediaStore import android.text.InputFilter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ProgressBar +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat import androidx.core.view.forEachIndexed import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment @@ -35,21 +31,16 @@ class LeaveReviewFragment : Fragment() { private val vm: MenuDetailViewModel by activityViewModels() - private val galleryLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - result.data?.data?.let { - vm.addImageUri(it, onFailure = { + private val pickMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(3)) { uris -> + val context = context ?: return@registerForActivityResult + uris.forEach { uri -> + vm.addImageUri( + context = context, + imageUri = uri, + onFailure = { requireContext().showToast(getString(R.string.leave_review_max_image_toast)) - }) - } - } - } - - private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - if (isGranted) { - launchGalleryIntent() - } else { - showToast("사진 업로드를 위해 사진 권한을 허용해 주세요.") + } + ) } } @@ -108,25 +99,42 @@ class LeaveReviewFragment : Fragment() { } ) - vm.imageUriList.observe(viewLifecycleOwner) { imageUriList -> - binding.imageLayout.forEachIndexed { index, view -> - (view as ReviewImageView).run { - if (index < imageUriList.size) { - setImage(imageUriList[index]) - visibility = View.VISIBLE - setOnDeleteClickListener( - object : ReviewImageView.OnDeleteClickListener { - override fun onClick() { - vm.deleteImageUri(index) - } + viewLifecycleOwner.lifecycleScope.launch { + vm.images.collect { imageUriList -> + binding.imageLayout.forEachIndexed { index, view -> + val frameLayout = view as? FrameLayout ?: return@forEachIndexed + val reviewImageView = frameLayout.getChildAt(0) as? ReviewImageView ?: return@forEachIndexed + val progressBar = frameLayout.getChildAt(1) as? ProgressBar ?: return@forEachIndexed + + if (index >= imageUriList.size) { + reviewImageView.visibility = View.GONE + return@forEachIndexed + } + + reviewImageView.visibility = View.VISIBLE + reviewImageView.setOnDeleteClickListener( + object : ReviewImageView.OnDeleteClickListener { + override fun onClick() { + vm.deleteImageUri(index) } - ) - } else { - visibility = View.GONE + } + ) + + when (val imageState = imageUriList[index]) { + is CompressedImageUiState.Compressing -> { + reviewImageView.setImage(imageState.originalImageUri) + progressBar.visibility = View.VISIBLE + } + + is CompressedImageUiState.Completed -> { + reviewImageView.setImage(imageState.compressedImageUri) + progressBar.visibility = View.GONE + } } + } + binding.imageLayout.setVisibleOrGone(imageUriList.isNotEmpty()) } - binding.imageLayout.setVisibleOrGone(imageUriList.isNotEmpty()) } vm.leaveReviewState.observe(viewLifecycleOwner) { @@ -141,10 +149,13 @@ class LeaveReviewFragment : Fragment() { lifecycleScope.launch { try { vm.leaveReview( - context = requireContext(), score = binding.rating.rating.toDouble(), comment = binding.commentEdit.text.toString().ifEmpty { binding.commentEdit.hint.toString() + }, + onFailure = { + showToast("이미지 압축 중입니다.") + return@leaveReview } ) showToast("평가가 등록되었습니다.") @@ -164,29 +175,7 @@ class LeaveReviewFragment : Fragment() { } binding.addImageButton.setOnClickListener { - requestPermission(onGranted = { - launchGalleryIntent() - }) + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } } - - private fun requestPermission(onGranted: () -> Unit) { - val permission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES else Manifest.permission.READ_EXTERNAL_STORAGE - if (ContextCompat.checkSelfPermission(requireActivity(), permission) == PackageManager.PERMISSION_GRANTED) { - onGranted() - } else { - requestPermissionLauncher.launch(permission) - } - } - - private fun launchGalleryIntent() { - val intent = Intent(Intent.ACTION_PICK) - .setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*") - galleryLauncher.launch(intent) - } - - companion object { - private const val GET_GALLERY_IMAGE = 1126 - private const val REQUEST_STORAGE_PERMISSION = 555 - } } diff --git a/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/MenuDetailViewModel.kt b/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/MenuDetailViewModel.kt index e5fb4e649..c879cd0a9 100644 --- a/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/MenuDetailViewModel.kt +++ b/app/src/main/java/com/wafflestudio/siksha2/ui/menuDetail/MenuDetailViewModel.kt @@ -11,13 +11,17 @@ import com.wafflestudio.siksha2.models.Menu import com.wafflestudio.siksha2.models.Review import com.wafflestudio.siksha2.repositories.MenuRepository import com.wafflestudio.siksha2.utils.ImageUtil -import com.wafflestudio.siksha2.utils.showToast import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File import java.io.IOException import javax.inject.Inject @@ -25,6 +29,11 @@ import javax.inject.Inject class MenuDetailViewModel @Inject constructor( private val menuRepository: MenuRepository ) : ViewModel() { + + companion object { + private const val MAX_IMAGE_COUNT = 3 + } + private val _menu = MutableLiveData() val menu: LiveData get() = _menu @@ -41,9 +50,8 @@ class MenuDetailViewModel @Inject constructor( val reviewDistribution: LiveData> get() = _reviewDistribution - private val _imageUriList = MutableLiveData>() - val imageUriList: LiveData> - get() = _imageUriList + private val _images = MutableStateFlow(emptyList()) + val images: StateFlow> = _images private val _imageUrlList = MutableLiveData>() val imageUrlList: LiveData> @@ -119,28 +127,51 @@ class MenuDetailViewModel @Inject constructor( } } - fun addImageUri(uri: Uri, onFailure: () -> Unit) { - val list = _imageUriList.value?.toMutableList() ?: mutableListOf() - if (list.size < 3) { - list.add(uri) - _imageUriList.value = list.toList() - } else { + fun addImageUri(context: Context, imageUri: Uri, onFailure: () -> Unit) { + if (images.value.size >= MAX_IMAGE_COUNT) { onFailure() + return + } + + viewModelScope.launch { + val compressing = CompressedImageUiState.Compressing(imageUri) + + _images.emit( + _images.value.toMutableList().apply { + add(compressing) + } + ) + + val compressedImageFile = withContext(Dispatchers.IO) { + ImageUtil.getCompressedImage(context, imageUri) + } + + _images.emit( + _images.value.toMutableList().apply { + set( + indexOf(compressing), + CompressedImageUiState.Completed( + compressedImageUri = Uri.fromFile(compressedImageFile), + compressedImageFile = compressedImageFile + ) + ) + } + ) } } fun deleteImageUri(index: Int, onFailure: () -> Unit = {}) { - val list = _imageUriList.value?.toMutableList() ?: mutableListOf() - if (index < list.size) { - list.removeAt(index) - _imageUriList.value = list.toList() - } else { + if (index >= images.value.size) { onFailure() + return + } + _images.value = images.value.toMutableList().apply { + removeAt(index) } } fun refreshUriList() { - _imageUriList.value = listOf() + _images.value = emptyList() } fun notifySendReviewEnd() { @@ -155,24 +186,33 @@ class MenuDetailViewModel @Inject constructor( _menu.postValue(updatedMenu) } - suspend fun leaveReview(context: Context, score: Double, comment: String) { - val menuId = _menu.value?.id ?: return - if (_imageUriList.value?.isNotEmpty() == true) { - context.showToast("이미지 압축 중입니다.") - _leaveReviewState.value = ReviewState.COMPRESSING - val imageList = _imageUriList.value?.map { - ImageUtil.getCompressedImage(context, it) - }?.map { file -> - val requestBody = file.asRequestBody("image/jpeg".toMediaTypeOrNull()) - MultipartBody.Part.createFormData("images", file.name, requestBody) + suspend fun leaveReview(score: Double, comment: String, onFailure: () -> Unit) { + val menuId = menu.value?.id ?: return + if (images.value.isEmpty()) { + leaveReviewWithoutImage(menuId, score, comment) + } else { + if (images.value.all { it is CompressedImageUiState.Completed }.not()) { + onFailure() + return } - val commentBody = MultipartBody.Part.createFormData("comment", comment) - imageList?.let { - menuRepository.leaveMenuReviewImage(menuId, score.toLong(), commentBody, imageList) + leaveReviewWithImage(menuId, score, comment, images.value) + } + } + + private suspend fun leaveReviewWithoutImage(menuId: Long, score: Double, comment: String) { + menuRepository.leaveMenuReview(menuId, score, comment) + } + + private suspend fun leaveReviewWithImage(menuId: Long, score: Double, comment: String, images: List) { + val commentBody = MultipartBody.Part.createFormData("comment", comment) + val imagesBody = withContext(Dispatchers.Default) { + images.map { + val compressedImageFile = (it as CompressedImageUiState.Completed).compressedImageFile + val requestBody = compressedImageFile.asRequestBody("image/jpeg".toMediaTypeOrNull()) + MultipartBody.Part.createFormData("images", compressedImageFile.name, requestBody) } - } else { - menuRepository.leaveMenuReview(menuId, score, comment) } + menuRepository.leaveMenuReviewImage(menuId, score.toLong(), commentBody, imagesBody) } enum class State { @@ -186,3 +226,11 @@ class MenuDetailViewModel @Inject constructor( COMPRESSING } } + +sealed interface CompressedImageUiState { + class Compressing(val originalImageUri: Uri) : CompressedImageUiState + class Completed( + val compressedImageUri: Uri, + val compressedImageFile: File, + ) : CompressedImageUiState +} diff --git a/app/src/main/res/layout/fragment_leave_review.xml b/app/src/main/res/layout/fragment_leave_review.xml index fe8583fd6..c9f4341dd 100644 --- a/app/src/main/res/layout/fragment_leave_review.xml +++ b/app/src/main/res/layout/fragment_leave_review.xml @@ -220,26 +220,57 @@ app:layout_constraintTop_toBottomOf="@id/comment_edit" app:layout_constraintStart_toStartOf="@id/comment_edit"> - - - + + + + + + + - - + + + + + + + android:layout_marginHorizontal="4dp"> + + + +