-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[REF/#198] ContentUriRequest Class의 이미지 압축 방식을 개선합니다. #200
base: develop
Are you sure you want to change the base?
Changes from all commits
d33a01a
1ccfc5c
48028a9
ec66a0e
d2484cb
a0ee0dd
f035fe3
59622bc
32f04f2
93ea73d
95c3275
1ea3d84
7d8cc4a
5f42a87
59e3457
063f77d
121b411
4e908e9
c2b3c98
60f54cf
77cd50d
54fa0ea
e6a0538
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,283 @@ | ||||||
package com.spoony.spoony.core.network | ||||||
|
||||||
import android.content.Context | ||||||
import android.graphics.Bitmap | ||||||
import android.graphics.BitmapFactory | ||||||
import android.graphics.ImageDecoder | ||||||
import android.graphics.Matrix | ||||||
import android.media.ExifInterface | ||||||
import android.net.Uri | ||||||
import android.os.Build | ||||||
import android.provider.MediaStore | ||||||
import android.util.Size | ||||||
import androidx.annotation.RequiresApi | ||||||
import com.spoony.spoony.BuildConfig | ||||||
import java.io.ByteArrayOutputStream | ||||||
import javax.inject.Inject | ||||||
import kotlin.math.max | ||||||
import kotlin.math.min | ||||||
import kotlinx.coroutines.Dispatchers | ||||||
import kotlinx.coroutines.withContext | ||||||
import okhttp3.MediaType | ||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull | ||||||
import okhttp3.MultipartBody | ||||||
import okhttp3.RequestBody | ||||||
import okio.BufferedSink | ||||||
import timber.log.Timber | ||||||
|
||||||
class ContentUriRequestBody @Inject constructor( | ||||||
context: Context, | ||||||
private val uri: Uri?, | ||||||
private val config: ImageConfig = ImageConfig.DEFAULT | ||||||
) : RequestBody() { | ||||||
private val contentResolver = context.contentResolver | ||||||
private var compressedImage: ByteArray? = null | ||||||
private var metadata: ImageMetadata? = null | ||||||
|
||||||
private data class ImageMetadata( | ||||||
val fileName: String, | ||||||
val size: Long, | ||||||
val mimeType: String? | ||||||
) { | ||||||
companion object { | ||||||
val EMPTY = ImageMetadata("", 0L, null) | ||||||
} | ||||||
} | ||||||
|
||||||
data class ImageConfig( | ||||||
val maxWidth: Int, | ||||||
val maxHeight: Int, | ||||||
val maxFileSize: Int, | ||||||
val initialQuality: Int, | ||||||
val minQuality: Int, | ||||||
val format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG | ||||||
) { | ||||||
companion object { | ||||||
val DEFAULT = ImageConfig( | ||||||
maxWidth = 1024, | ||||||
maxHeight = 1024, | ||||||
maxFileSize = 1024 * 1024, | ||||||
initialQuality = 100, | ||||||
minQuality = 20 | ||||||
) | ||||||
} | ||||||
} | ||||||
|
||||||
init { | ||||||
uri?.let { | ||||||
metadata = extractMetadata(it) | ||||||
} | ||||||
} | ||||||
|
||||||
private fun extractMetadata(uri: Uri): ImageMetadata = | ||||||
runCatching { | ||||||
contentResolver.query( | ||||||
uri, | ||||||
arrayOf( | ||||||
MediaStore.Images.Media.SIZE, | ||||||
MediaStore.Images.Media.DISPLAY_NAME | ||||||
), | ||||||
null, | ||||||
null, | ||||||
null | ||||||
)?.use { cursor -> | ||||||
if (cursor.moveToFirst()) { | ||||||
ImageMetadata( | ||||||
fileName = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)), | ||||||
size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)), | ||||||
mimeType = contentResolver.getType(uri) | ||||||
) | ||||||
} else { | ||||||
ImageMetadata.EMPTY | ||||||
} | ||||||
} ?: ImageMetadata.EMPTY | ||||||
}.getOrDefault(ImageMetadata.EMPTY) | ||||||
|
||||||
suspend fun prepareImage(): Result<Unit> = runCatching { | ||||||
withContext(Dispatchers.IO) { | ||||||
uri?.let { safeUri -> | ||||||
compressImage(safeUri).onSuccess { bytes -> | ||||||
compressedImage = bytes | ||||||
} | ||||||
} | ||||||
} | ||||||
} | ||||||
|
||||||
private suspend fun compressImage(uri: Uri): Result<ByteArray> = | ||||||
withContext(Dispatchers.IO) { | ||||||
loadBitmap(uri).map { bitmap -> | ||||||
compressBitmap(bitmap).also { | ||||||
bitmap.recycle() | ||||||
} | ||||||
} | ||||||
} | ||||||
|
||||||
@RequiresApi(Build.VERSION_CODES.Q) | ||||||
private suspend fun loadBitmapWithImageDecoder(uri: Uri): Result<Bitmap> = | ||||||
withContext(Dispatchers.IO) { | ||||||
runCatching { | ||||||
val source = ImageDecoder.createSource(contentResolver, uri) | ||||||
ImageDecoder.decodeBitmap(source) { decoder, info, _ -> | ||||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE | ||||||
decoder.isMutableRequired = true | ||||||
calculateTargetSize(info.size.width, info.size.height).let { size -> | ||||||
decoder.setTargetSize(size.width, size.height) | ||||||
} | ||||||
} | ||||||
} | ||||||
} | ||||||
Comment on lines
+115
to
+128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 신기하네여~ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 근데 지금 찾아보니 ImageDecoder 도입이 API 28이네요! 분기 제거해도 될듯..???? |
||||||
|
||||||
private suspend fun loadBitmap(uri: Uri): Result<Bitmap> = | ||||||
withContext(Dispatchers.IO) { | ||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { | ||||||
loadBitmapWithImageDecoder(uri) | ||||||
} else { | ||||||
loadBitmapLegacy(uri) | ||||||
} | ||||||
} | ||||||
Comment on lines
+130
to
+137
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p4) 사소하지만 함수의 선언 순서대로 사용하는 것을 저는 더 선호합니다. 아래 순서대로 작성하는건 어떨까요~ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저도 더 좋은것 같습니다~ 가독성 챙기는 방향으로 수정하겠습니다! |
||||||
|
||||||
private suspend fun loadBitmapLegacy(uri: Uri): Result<Bitmap> = | ||||||
withContext(Dispatchers.IO) { | ||||||
runCatching { | ||||||
val options = BitmapFactory.Options().apply { | ||||||
inJustDecodeBounds = true | ||||||
} | ||||||
|
||||||
contentResolver.openInputStream(uri)?.use { input -> | ||||||
BitmapFactory.decodeStream(input, null, options) | ||||||
} | ||||||
|
||||||
options.apply { | ||||||
inJustDecodeBounds = false | ||||||
inSampleSize = calculateInSampleSize(outWidth, outHeight) | ||||||
inPreferredConfig = Bitmap.Config.ARGB_8888 | ||||||
} | ||||||
|
||||||
contentResolver.openInputStream(uri)?.use { input -> | ||||||
BitmapFactory.decodeStream(input, null, options) | ||||||
}?.let { bitmap -> | ||||||
getOrientation(uri).let { orientation -> | ||||||
if (orientation != ORIENTATION_NORMAL) { | ||||||
rotateBitmap(bitmap, orientation) | ||||||
} else { | ||||||
bitmap | ||||||
} | ||||||
} | ||||||
} ?: error("비트맵 디코딩 실패") | ||||||
} | ||||||
} | ||||||
|
||||||
private suspend fun compressBitmap(bitmap: Bitmap): ByteArray = | ||||||
withContext(Dispatchers.IO) { | ||||||
val estimatedSize = min(bitmap.byteCount / 4, config.maxFileSize) | ||||||
ByteArrayOutputStream(estimatedSize).use { buffer -> | ||||||
var lowerQuality = config.minQuality | ||||||
var upperQuality = config.initialQuality | ||||||
var bestQuality = lowerQuality | ||||||
|
||||||
while (lowerQuality <= upperQuality) { | ||||||
val midQuality = (lowerQuality + upperQuality) / 2 | ||||||
buffer.reset() | ||||||
|
||||||
bitmap.compress(config.format, midQuality, buffer) | ||||||
|
||||||
if (buffer.size() <= config.maxFileSize) { | ||||||
bestQuality = midQuality | ||||||
lowerQuality = midQuality + 1 | ||||||
} else { | ||||||
upperQuality = midQuality - 1 | ||||||
} | ||||||
} | ||||||
|
||||||
if (BuildConfig.DEBUG) { | ||||||
Timber.d("Compression completed - Quality: $bestQuality, Size: ${buffer.size()} bytes") | ||||||
} | ||||||
Comment on lines
+192
to
+194
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p3) 저희 Timeber 셋팅에서 이미 DEBUG 모드에서만 뜨게 설정해뒀을껄용? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이미 설정 해놨지롱!!ㅎㅎㅎ 그냥 바로 Timber 사용해도 됩니다😊 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 몰랐어잉~ 수정할게요~ |
||||||
return@use buffer.toByteArray() | ||||||
} | ||||||
} | ||||||
|
||||||
private fun getOrientation(uri: Uri): Int { | ||||||
contentResolver.query( | ||||||
uri, | ||||||
arrayOf(MediaStore.Images.Media.ORIENTATION), | ||||||
null, | ||||||
null, | ||||||
null | ||||||
)?.use { cursor -> | ||||||
if (cursor.moveToFirst()) { | ||||||
return cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION)) | ||||||
} | ||||||
} | ||||||
return getExifOrientation(uri) | ||||||
} | ||||||
|
||||||
private fun getExifOrientation(uri: Uri): Int = | ||||||
contentResolver.openInputStream(uri)?.use { input -> | ||||||
ExifInterface(input).getAttributeInt( | ||||||
ExifInterface.TAG_ORIENTATION, | ||||||
ExifInterface.ORIENTATION_NORMAL | ||||||
) | ||||||
}?.let { orientation -> | ||||||
when (orientation) { | ||||||
ExifInterface.ORIENTATION_ROTATE_90 -> ORIENTATION_ROTATE_90 | ||||||
ExifInterface.ORIENTATION_ROTATE_180 -> ORIENTATION_ROTATE_180 | ||||||
ExifInterface.ORIENTATION_ROTATE_270 -> ORIENTATION_ROTATE_270 | ||||||
else -> ORIENTATION_NORMAL | ||||||
} | ||||||
} ?: ORIENTATION_NORMAL | ||||||
|
||||||
private fun rotateBitmap(bitmap: Bitmap, angle: Int): Bitmap = | ||||||
runCatching { | ||||||
Matrix().apply { | ||||||
postRotate(angle.toFloat()) | ||||||
}.let { matrix -> | ||||||
Bitmap.createBitmap( | ||||||
bitmap, | ||||||
0, | ||||||
0, | ||||||
bitmap.width, | ||||||
bitmap.height, | ||||||
matrix, | ||||||
true | ||||||
) | ||||||
} | ||||||
}.onSuccess { rotatedBitmap -> | ||||||
if (rotatedBitmap != bitmap) { | ||||||
bitmap.recycle() | ||||||
} | ||||||
}.getOrDefault(bitmap) | ||||||
|
||||||
private fun calculateInSampleSize(width: Int, height: Int): Int { | ||||||
val ratio = max(1, min(height / config.maxHeight, width / config.maxWidth)) | ||||||
return Integer.highestOneBit(ratio) | ||||||
} | ||||||
|
||||||
private fun calculateTargetSize(width: Int, height: Int): Size { | ||||||
val ratio = width.toFloat() / height.toFloat() | ||||||
return if (width > height) { | ||||||
Size(config.maxWidth, (config.maxWidth / ratio).toInt()) | ||||||
} else { | ||||||
Size((config.maxHeight * ratio).toInt(), config.maxHeight) | ||||||
} | ||||||
} | ||||||
|
||||||
override fun contentLength(): Long = compressedImage?.size?.toLong() ?: -1L | ||||||
override fun contentType(): MediaType? = metadata?.mimeType?.toMediaTypeOrNull() | ||||||
override fun writeTo(sink: BufferedSink) { | ||||||
compressedImage?.let(sink::write) | ||||||
} | ||||||
|
||||||
fun toFormData(name: String): MultipartBody.Part = MultipartBody.Part.createFormData( | ||||||
name, | ||||||
metadata?.fileName ?: DEFAULT_FILE_NAME, | ||||||
this | ||||||
) | ||||||
|
||||||
companion object { | ||||||
private const val ORIENTATION_NORMAL = 0 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p4) 이건 어떨지? Normal이라는게 의미가 모호한거같아여
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 음~ 확실히 Default가 더 친숙하네요! 수정하겠습니당 |
||||||
private const val ORIENTATION_ROTATE_90 = 90 | ||||||
private const val ORIENTATION_ROTATE_180 = 180 | ||||||
private const val ORIENTATION_ROTATE_270 = 270 | ||||||
private const val DEFAULT_FILE_NAME = "image.jpg" | ||||||
} | ||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
p4) 위에 import하면 이렇게도 접근 가능하답니다~ 그런데 명시적으로 표시하기 위해 import하지 않는것도 좋은 방법이라 생각해서 참고만 하시길!