From d33a01af1ab426dd77fc4052c3fa4495ad960a96 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Mon, 10 Feb 2025 15:14:29 +0900 Subject: [PATCH 01/30] =?UTF-8?q?[REF/#193]=20=EA=B0=9C=EC=84=A0=EB=90=9C?= =?UTF-8?q?=20=EC=95=95=EC=B6=95=20=EB=B0=A9=EC=8B=9D=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20=EB=B0=8F=20=EC=84=B1=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryimpl/RegisterRepositoryImpl.kt | 91 ++++++++++++++++++- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index e70b6ee8..c9760e1d 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -2,6 +2,11 @@ package com.spoony.spoony.data.repositoryimpl import android.content.Context import android.net.Uri +import android.os.Debug +import androidx.core.graphics.component1 +import androidx.core.graphics.component2 +import com.spoony.spoony.core.network.ContentUriRequestBody +import com.spoony.spoony.core.network.ContentUriRequestBodyLegacy import com.spoony.spoony.data.datasource.CategoryDataSource import com.spoony.spoony.data.datasource.PlaceDataSource import com.spoony.spoony.data.dto.request.RegisterPostRequestDto @@ -11,10 +16,14 @@ import com.spoony.spoony.domain.entity.CategoryEntity import com.spoony.spoony.domain.entity.PlaceEntity import com.spoony.spoony.domain.repository.RegisterRepository import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody +import timber.log.Timber +import javax.inject.Inject +import kotlin.system.measureTimeMillis class RegisterRepositoryImpl @Inject constructor( private val placeDataSource: PlaceDataSource, @@ -51,7 +60,6 @@ class RegisterRepositoryImpl @Inject constructor( menuList: List, photos: List ): Result = runCatching { - // 1. Request DTO를 RequestBody로 변환 val requestDto = RegisterPostRequestDto( userId = userId, title = title, @@ -68,9 +76,9 @@ class RegisterRepositoryImpl @Inject constructor( val jsonString = Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto) val requestBody = jsonString.toRequestBody("application/json".toMediaType()) - // 2. ContentUriRequestBody를 사용하여 이미지 변환 + // 각 이미지에 대해 압축 성능 및 메모리 사용량 평가 수행 val photoParts = photos.map { uri -> - ContentUriRequestBody(context, uri).toFormData("photos") + testCompressionPerformance(uri) } postService.registerPost( @@ -78,4 +86,79 @@ class RegisterRepositoryImpl @Inject constructor( photos = photoParts ).data } + + /** + * 이미지 압축 성능 및 메모리 사용량 평가 함수 + * + * - 원본 이미지 크기 (ContentResolver를 통해 측정) + * - 압축 처리 전후의 네이티브 힙 메모리 사용량 측정 (Debug.getNativeHeapAllocatedSize) + * - 압축 후 이미지 크기 (RequestBody의 contentLength()) + * - 실행 시간 측정 + * + * 이를 통해 이미지 압축에 사용되는 메모리가 실제 원본 이미지 크기와 비교해 과도하게 할당되는지 확인하여 OOM 위험을 판단할 수 있습니다. + */ + private suspend fun testCompressionPerformance(uri: Uri): MultipartBody.Part { + val isTestMode = false // true이면 기존 방식, false이면 개선된 방식 + + // 1. 원본 이미지 크기 측정 (바이트 단위) + val originalSize: Long = context.contentResolver.openInputStream(uri)?.available()?.toLong() ?: -1L + + // 2. 압축 전 네이티브 힙 메모리 사용량 측정 (바이트 단위) + val nativeHeapBefore: Long = getNativeHeapAllocatedSize() + + // 3. 이미지 압축 처리 및 실행 시간 측정 + lateinit var result: MultipartBody.Part + val elapsedTime = measureTimeMillis { + result = if (isTestMode) { + // 기존 압축 방식 (예: ContentUriRequestBodyLegacy 사용) + ContentUriRequestBodyLegacy(context, uri).toFormData("photos") + } else { + // 개선된 압축 방식 (예: ContentUriRequestBody 사용, prepareImage() 호출) + ContentUriRequestBody(context, uri).apply { + prepareImage() + }.toFormData("photos") + } + } + + // 4. 압축 후 네이티브 힙 메모리 사용량 측정 (바이트 단위) + val nativeHeapAfter: Long = getNativeHeapAllocatedSize() + + // 5. 압축에 사용된 네이티브 힙 메모리 (바이트 단위) + val memoryUsedForCompression: Long = nativeHeapAfter - nativeHeapBefore + + // 6. 압축 후 이미지 크기 측정 (RequestBody의 contentLength()) + val compressedSize: Long = result.body.contentLength() + + // 7. 로그 출력 (MB 단위로 변환) + val originalSizeMB = originalSize / (1024f * 1024f) + val compressedSizeMB = compressedSize / (1024f * 1024f) + val memoryUsedMB = memoryUsedForCompression / (1024f * 1024f) + + Timber.d( + """ + ✨ 이미지 압축 성능 및 메모리 사용량 평가 (${if (isTestMode) "기존" else "개선"} 방식): + 📊 원본 이미지 크기: $originalSizeMB MB + 📉 압축 후 이미지 크기: $compressedSizeMB MB + 📈 압축률: ${"%.2f".format((1 - (compressedSize.toFloat() / originalSize)) * 100)}% + ⏱️ 실행 시간: ${elapsedTime}ms + 💾 압축에 사용된 네이티브 힙 메모리: $memoryUsedMB MB + """.trimIndent() + ) + + // 원본 이미지 크기와 비교하여, 압축에 사용된 메모리가 과도하다면 경고 로그 출력 (OOM 위험 판단) + if (memoryUsedForCompression > originalSize) { + Timber.w("경고: 압축에 사용된 메모리($memoryUsedMB MB)가 원본 이미지 크기($originalSizeMB MB)보다 큽니다. OOM 위험이 있을 수 있습니다.") + } + + return result + } + + /** + * Debug API를 사용하여 네이티브 힙에 할당된 메모리 크기를 측정합니다. + * 반환 값은 바이트 단위입니다. + */ + private fun getNativeHeapAllocatedSize(): Long { + return Debug.getNativeHeapAllocatedSize() + } + } From 1ccfc5c2911ebeab311d463d5cb285fd3c5345ad Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Mon, 10 Feb 2025 15:14:38 +0900 Subject: [PATCH 02/30] =?UTF-8?q?[REF/#193]=20=EA=B0=9C=EC=84=A0=EB=90=9C?= =?UTF-8?q?=20=EC=95=95=EC=B6=95=20=EB=B0=A9=EC=8B=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/network/ContentUriRequestBody.kt | 412 ++++++++++++++++++ .../repositoryimpl/ContentUriRequestBody.kt | 111 ----- 2 files changed, 412 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt delete mode 100644 app/src/main/java/com/spoony/spoony/data/repositoryimpl/ContentUriRequestBody.kt diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt new file mode 100644 index 00000000..e660d01b --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -0,0 +1,412 @@ +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 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 +import java.io.ByteArrayOutputStream +import javax.inject.Inject + +class ContentUriRequestBody @Inject constructor( + context: Context, + private val uri: Uri?, +) : RequestBody() { + private val contentResolver = context.contentResolver + private var compressedImage: ByteArray? = null + private var metadata: ImageMetadata? = null + + private data class ImageMetadata private constructor( + val fileName: String, + val size: Long = 0L, + val mimeType: String? + ) { + companion object { + fun create(fileName: String, size: Long, mimeType: String?) = + ImageMetadata(fileName, size, mimeType) + } + } + + init { + uri?.let { + metadata = extractMetadata(it) + } + } + + private fun extractMetadata(uri: Uri): ImageMetadata { + var fileName = "" + var size = 0L + + contentResolver.query( + uri, + arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + size = cursor.getLong( + cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE) + ) + fileName = cursor.getString( + cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) + ) + } + } + + return ImageMetadata.create( + fileName = fileName, + size = size, + mimeType = contentResolver.getType(uri) + ) + } + + suspend fun prepareImage() = withContext(Dispatchers.IO) { + uri?.let { safeUri -> + runCatching { + compressedImage = compressImage(safeUri) + }.onFailure { error -> + Timber.e(error, "Image compression failed") + throw error + } + } + } + + private suspend fun compressImage(uri: Uri): ByteArray = withContext(Dispatchers.IO) { + val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + loadBitmapWithImageDecoder(uri) + } else { + loadBitmapLegacy(uri) + } + + compressBitmap(bitmap).also { + bitmap.recycle() + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private suspend fun loadBitmapWithImageDecoder(uri: Uri): Bitmap = withContext(Dispatchers.IO) { + val source = ImageDecoder.createSource(contentResolver, uri) + ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.isMutableRequired = true + + val size = calculateTargetSize(info.size.width, info.size.height) + decoder.setTargetSize(size.width, size.height) + } + } + + private suspend fun loadBitmapLegacy(uri: Uri): Bitmap = withContext(Dispatchers.IO) { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + + requireNotNull( + contentResolver.openInputStream(uri)?.use { input -> + BitmapFactory.decodeStream(input, null, options) + options.apply { + inJustDecodeBounds = false + inSampleSize = calculateInSampleSize(this, MAX_WIDTH, MAX_HEIGHT) + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + + contentResolver.openInputStream(uri)?.use { secondInput -> + BitmapFactory.decodeStream(secondInput, null, options) + } + } + ) { "Failed to decode bitmap" } + }?.let { bitmap -> + val orientation = getOrientation(uri) + if (orientation != ORIENTATION_NORMAL) { + rotateBitmap(bitmap, orientation) + } else { + bitmap + } + } ?: throw IllegalStateException("Failed to load bitmap") + + private fun getOrientation(uri: Uri): Int = + contentResolver.query( + uri, + arrayOf(MediaStore.Images.Media.ORIENTATION), + null, + null, + null + )?.use { + if (it.moveToFirst()) { + it.getInt(it.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION)) + } else { + ORIENTATION_NORMAL + } + } ?: getExifOrientation(uri) + + private fun getExifOrientation(uri: Uri): Int = + contentResolver.openInputStream(uri)?.use { input -> + val exif = ExifInterface(input) + when (exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + )) { + 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 = + Bitmap.createBitmap( + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + Matrix().apply { postRotate(angle.toFloat()) }, + true + ).also { + if (it != bitmap) { + bitmap.recycle() + } + } + + private suspend fun compressBitmap(bitmap: Bitmap): ByteArray = withContext(Dispatchers.IO) { + val maxFileSize = MAX_FILE_SIZE_BYTES + var lowerQuality = MIN_QUALITY // 최소 품질 (예: 20) + var upperQuality = INITIAL_QUALITY // 초기 품질 (예: 100) + var bestQuality = lowerQuality // 조건을 만족하는 최고 품질 값 + var bestByteArray: ByteArray = ByteArray(0) + + // 이진 탐색을 통해 파일 크기가 maxFileSize 이하가 되는 최대 품질을 찾음 + while (lowerQuality <= upperQuality) { + val midQuality = (lowerQuality + upperQuality) / 2 + + // 임시 ByteArrayOutputStream에 bitmap을 midQuality로 압축 + val byteArray = ByteArrayOutputStream().use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, midQuality, outputStream) + outputStream.toByteArray() + } + val size = byteArray.size + + if (size <= maxFileSize) { + // 압축 결과가 1MB 이하이면, 더 높은 품질을 시도하기 위해 하한선을 올림 + bestQuality = midQuality + bestByteArray = byteArray + lowerQuality = midQuality + 1 + } else { + // 파일 크기가 너무 크면, 상한선을 낮춤 + upperQuality = midQuality - 1 + } + } + + Timber.d("Selected quality: $bestQuality for compressed image size: ${bestByteArray.size} bytes") + bestByteArray + } + + + private fun calculateInSampleSize( + options: BitmapFactory.Options, + reqWidth: Int, + reqHeight: Int, + ): Int { + 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 + + while (halfHeight / inSampleSize >= reqHeight && + halfWidth / inSampleSize >= reqWidth + ) { + inSampleSize *= 2 + } + } + + return inSampleSize + } + + private fun calculateTargetSize(width: Int, height: Int): Size { + val ratio = width.toFloat() / height.toFloat() + return if (width > height) { + Size(MAX_WIDTH, (MAX_WIDTH / ratio).toInt()) + } else { + Size((MAX_HEIGHT * ratio).toInt(), MAX_HEIGHT) + } + } + + 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 + ) + + private companion object { + // 이미지 크기 관련 + private const val MAX_WIDTH = 1024 + private const val MAX_HEIGHT = 1024 + private const val MAX_FILE_SIZE_BYTES = 1024 * 1024 // 1MB + + // 압축 품질 관련 + private const val INITIAL_QUALITY = 100 + private const val MIN_QUALITY = 20 + private const val QUALITY_DECREMENT = 5 + + // 이미지 회전 관련 + private const val ORIENTATION_NORMAL = 0 + 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" + } +} + +class ContentUriRequestBodyLegacy( + context: Context, + private val uri: Uri?, +) : RequestBody() { + private val contentResolver = context.contentResolver + + private var fileName = "" + private var size = -1L + private var compressedImage: ByteArray? = null + + init { + if (uri != null) { + contentResolver.query( + uri, + arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + size = + cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)) + fileName = + cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) + } + } + + compressBitmap() + } + } + + private fun compressBitmap() { + if (uri != null) { + var originalBitmap: Bitmap + val exif: ExifInterface + + contentResolver.openInputStream(uri).use { inputStream -> + if (inputStream == null) return + val option = BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(this, MAX_WIDTH, MAX_HEIGHT) + } + originalBitmap = BitmapFactory.decodeStream(inputStream, null, option) ?: return + exif = ExifInterface(inputStream) + } + + var orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> orientation = 90 + ExifInterface.ORIENTATION_ROTATE_180 -> orientation = 180 + ExifInterface.ORIENTATION_ROTATE_270 -> orientation = 270 + } + + if (orientation >= 90) { + val matrix = Matrix().apply { + setRotate(orientation.toFloat()) + } + + val rotatedBitmap = Bitmap.createBitmap( + originalBitmap, + 0, + 0, + originalBitmap.width, + originalBitmap.height, + matrix, + true + ) + originalBitmap.recycle() + originalBitmap = rotatedBitmap + } + + val outputStream = ByteArrayOutputStream() + val imageSizeMb = size / (MAX_WIDTH * MAX_HEIGHT.toDouble()) + outputStream.use { + val compressRate = ((IMAGE_SIZE_MB / imageSizeMb) * 100).toInt() + originalBitmap.compress( + Bitmap.CompressFormat.JPEG, + if (imageSizeMb >= IMAGE_SIZE_MB) compressRate else 100, + it, + ) + } + compressedImage = outputStream.toByteArray() + size = compressedImage?.size?.toLong() ?: -1L + } + } + + private fun calculateInSampleSize( + options: BitmapFactory.Options, + reqWidth: Int, + reqHeight: Int, + ): Int { + 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 + + while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 + } + } + + return inSampleSize + } + + private fun getFileName() = fileName + + override fun contentLength(): Long = size + + override fun contentType(): MediaType? = + uri?.let { contentResolver.getType(it)?.toMediaTypeOrNull() } + + override fun writeTo(sink: BufferedSink) { + compressedImage?.let(sink::write) + } + + fun toFormData(name: String) = MultipartBody.Part.createFormData(name, getFileName(), this) + + companion object { + const val IMAGE_SIZE_MB = 1 + const val MAX_WIDTH = 1024 + const val MAX_HEIGHT = 1024 + } +} diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/ContentUriRequestBody.kt deleted file mode 100644 index 0878ad93..00000000 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/ContentUriRequestBody.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.spoony.spoony.data.repositoryimpl - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import android.provider.MediaStore -import java.io.ByteArrayOutputStream -import okhttp3.MediaType -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.MultipartBody -import okhttp3.RequestBody -import okio.BufferedSink - -class ContentUriRequestBody( - context: Context, - private val uri: Uri? -) : RequestBody() { - private val contentResolver = context.contentResolver - - private var fileName = "" - private var size = -1L - private var compressedImage: ByteArray? = null - - init { - if (uri != null) { - contentResolver.query( - uri, - arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), - null, - null, - null - )?.use { cursor -> - if (cursor.moveToFirst()) { - size = - cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)) - fileName = - cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) - } - } - - compressBitmap() - } - } - - private fun compressBitmap() { - if (uri != null) { - var originalBitmap: Bitmap - - contentResolver.openInputStream(uri).use { inputStream -> - if (inputStream == null) return - val option = BitmapFactory.Options().apply { - inSampleSize = calculateInSampleSize(this, MAX_WIDTH, MAX_HEIGHT) - } - originalBitmap = BitmapFactory.decodeStream(inputStream, null, option) ?: return - } - - val outputStream = ByteArrayOutputStream() - val imageSizeMb = size / (MAX_WIDTH * MAX_HEIGHT.toDouble()) - outputStream.use { - val compressRate = ((IMAGE_SIZE_MB / imageSizeMb) * 100).toInt() - originalBitmap.compress( - Bitmap.CompressFormat.JPEG, - if (imageSizeMb >= IMAGE_SIZE_MB) compressRate else 100, - it - ) - } - compressedImage = outputStream.toByteArray() - size = compressedImage?.size?.toLong() ?: -1L - } - } - - private fun calculateInSampleSize( - options: BitmapFactory.Options, - reqWidth: Int, - reqHeight: Int - ): Int { - 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 - - while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { - inSampleSize *= 2 - } - } - - return inSampleSize - } - - private fun getFileName() = fileName - - override fun contentLength(): Long = size - - override fun contentType(): MediaType? = - uri?.let { contentResolver.getType(it)?.toMediaTypeOrNull() } - - override fun writeTo(sink: BufferedSink) { - compressedImage?.let(sink::write) - } - - fun toFormData(name: String) = MultipartBody.Part.createFormData(name, getFileName(), this) - - companion object { - const val IMAGE_SIZE_MB = 1 - const val MAX_WIDTH = 1024 - const val MAX_HEIGHT = 1024 - } -} From 48028a9e41a58bb047c431461099edf5d2f33af3 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Mon, 10 Feb 2025 16:34:37 +0900 Subject: [PATCH 03/30] =?UTF-8?q?[MOD/#193]=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spoony/spoony/core/network/ContentUriRequestBody.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index e660d01b..b06e5915 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -129,14 +129,14 @@ class ContentUriRequestBody @Inject constructor( } } ) { "Failed to decode bitmap" } - }?.let { bitmap -> + }.let { bitmap -> val orientation = getOrientation(uri) if (orientation != ORIENTATION_NORMAL) { rotateBitmap(bitmap, orientation) } else { bitmap } - } ?: throw IllegalStateException("Failed to load bitmap") + } private fun getOrientation(uri: Uri): Int = contentResolver.query( @@ -187,7 +187,7 @@ class ContentUriRequestBody @Inject constructor( var lowerQuality = MIN_QUALITY // 최소 품질 (예: 20) var upperQuality = INITIAL_QUALITY // 초기 품질 (예: 100) var bestQuality = lowerQuality // 조건을 만족하는 최고 품질 값 - var bestByteArray: ByteArray = ByteArray(0) + var bestByteArray = ByteArray(0) // 이진 탐색을 통해 파일 크기가 maxFileSize 이하가 되는 최대 품질을 찾음 while (lowerQuality <= upperQuality) { @@ -270,7 +270,6 @@ class ContentUriRequestBody @Inject constructor( // 압축 품질 관련 private const val INITIAL_QUALITY = 100 private const val MIN_QUALITY = 20 - private const val QUALITY_DECREMENT = 5 // 이미지 회전 관련 private const val ORIENTATION_NORMAL = 0 From ec66a0ec38bce681072186053c762d5c2aec3910 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Mon, 10 Feb 2025 16:37:07 +0900 Subject: [PATCH 04/30] =?UTF-8?q?[MOD/#193]=20lint=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spoony/spoony/core/database/SearchDao.kt | 1 - .../core/network/ContentUriRequestBody.kt | 35 ++++++++++--------- .../data/repositoryimpl/MapRepositoryImpl.kt | 3 +- .../repositoryimpl/RegisterRepositoryImpl.kt | 6 ++-- .../spoony/presentation/main/MainNavigator.kt | 2 +- .../spoony/presentation/map/MapScreen.kt | 3 +- .../spoony/presentation/map/MapViewModel.kt | 7 ++-- .../map/navigaion/MapNavigation.kt | 3 +- .../map/search/MapSearchScreen.kt | 6 ++-- .../map/search/MapSearchViewModel.kt | 4 +-- 10 files changed, 33 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/database/SearchDao.kt b/app/src/main/java/com/spoony/spoony/core/database/SearchDao.kt index 5d9dc363..bdae0a3b 100644 --- a/app/src/main/java/com/spoony/spoony/core/database/SearchDao.kt +++ b/app/src/main/java/com/spoony/spoony/core/database/SearchDao.kt @@ -2,7 +2,6 @@ package com.spoony.spoony.core.database import androidx.room.Dao import androidx.room.Insert -import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import com.spoony.spoony.core.database.entity.SearchEntity diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index b06e5915..73073a28 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -11,6 +11,8 @@ import android.os.Build import android.provider.MediaStore import android.util.Size import androidx.annotation.RequiresApi +import java.io.ByteArrayOutputStream +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType @@ -19,12 +21,10 @@ import okhttp3.MultipartBody import okhttp3.RequestBody import okio.BufferedSink import timber.log.Timber -import java.io.ByteArrayOutputStream -import javax.inject.Inject class ContentUriRequestBody @Inject constructor( context: Context, - private val uri: Uri?, + private val uri: Uri? ) : RequestBody() { private val contentResolver = context.contentResolver private var compressedImage: ByteArray? = null @@ -56,7 +56,7 @@ class ContentUriRequestBody @Inject constructor( arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), null, null, - null, + null )?.use { cursor -> if (cursor.moveToFirst()) { size = cursor.getLong( @@ -156,10 +156,12 @@ class ContentUriRequestBody @Inject constructor( private fun getExifOrientation(uri: Uri): Int = contentResolver.openInputStream(uri)?.use { input -> val exif = ExifInterface(input) - when (exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL - )) { + when ( + exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + ) { ExifInterface.ORIENTATION_ROTATE_90 -> ORIENTATION_ROTATE_90 ExifInterface.ORIENTATION_ROTATE_180 -> ORIENTATION_ROTATE_180 ExifInterface.ORIENTATION_ROTATE_270 -> ORIENTATION_ROTATE_270 @@ -184,9 +186,9 @@ class ContentUriRequestBody @Inject constructor( private suspend fun compressBitmap(bitmap: Bitmap): ByteArray = withContext(Dispatchers.IO) { val maxFileSize = MAX_FILE_SIZE_BYTES - var lowerQuality = MIN_QUALITY // 최소 품질 (예: 20) - var upperQuality = INITIAL_QUALITY // 초기 품질 (예: 100) - var bestQuality = lowerQuality // 조건을 만족하는 최고 품질 값 + var lowerQuality = MIN_QUALITY // 최소 품질 (예: 20) + var upperQuality = INITIAL_QUALITY // 초기 품질 (예: 100) + var bestQuality = lowerQuality // 조건을 만족하는 최고 품질 값 var bestByteArray = ByteArray(0) // 이진 탐색을 통해 파일 크기가 maxFileSize 이하가 되는 최대 품질을 찾음 @@ -215,11 +217,10 @@ class ContentUriRequestBody @Inject constructor( bestByteArray } - private fun calculateInSampleSize( options: BitmapFactory.Options, reqWidth: Int, - reqHeight: Int, + reqHeight: Int ): Int { val (height: Int, width: Int) = options.run { outHeight to outWidth } var inSampleSize = 1 @@ -284,7 +285,7 @@ class ContentUriRequestBody @Inject constructor( class ContentUriRequestBodyLegacy( context: Context, - private val uri: Uri?, + private val uri: Uri? ) : RequestBody() { private val contentResolver = context.contentResolver @@ -299,7 +300,7 @@ class ContentUriRequestBodyLegacy( arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), null, null, - null, + null )?.use { cursor -> if (cursor.moveToFirst()) { size = @@ -362,7 +363,7 @@ class ContentUriRequestBodyLegacy( originalBitmap.compress( Bitmap.CompressFormat.JPEG, if (imageSizeMb >= IMAGE_SIZE_MB) compressRate else 100, - it, + it ) } compressedImage = outputStream.toByteArray() @@ -373,7 +374,7 @@ class ContentUriRequestBodyLegacy( private fun calculateInSampleSize( options: BitmapFactory.Options, reqWidth: Int, - reqHeight: Int, + reqHeight: Int ): Int { val (height: Int, width: Int) = options.run { outHeight to outWidth } var inSampleSize = 1 diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/MapRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/MapRepositoryImpl.kt index d88fa77d..6726ae1f 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/MapRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/MapRepositoryImpl.kt @@ -1,7 +1,6 @@ package com.spoony.spoony.data.repositoryimpl import com.spoony.spoony.core.database.SearchDao -import com.spoony.spoony.core.database.entity.SearchEntity import com.spoony.spoony.data.datasource.MapRemoteDataSource import com.spoony.spoony.data.datasource.PostRemoteDataSource import com.spoony.spoony.data.mapper.toDomain @@ -14,7 +13,7 @@ import javax.inject.Inject class MapRepositoryImpl @Inject constructor( private val mapRemoteDataSource: MapRemoteDataSource, private val postRemoteDataSource: PostRemoteDataSource, - private val searchDao: SearchDao, + private val searchDao: SearchDao ) : MapRepository { override suspend fun searchLocation(query: String): Result> = runCatching { diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index c9760e1d..96f76ecf 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -16,14 +16,13 @@ import com.spoony.spoony.domain.entity.CategoryEntity import com.spoony.spoony.domain.entity.PlaceEntity import com.spoony.spoony.domain.repository.RegisterRepository import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlin.system.measureTimeMillis import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType -import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody import timber.log.Timber -import javax.inject.Inject -import kotlin.system.measureTimeMillis class RegisterRepositoryImpl @Inject constructor( private val placeDataSource: PlaceDataSource, @@ -160,5 +159,4 @@ class RegisterRepositoryImpl @Inject constructor( private fun getNativeHeapAllocatedSize(): Long { return Debug.getNativeHeapAllocatedSize() } - } diff --git a/app/src/main/java/com/spoony/spoony/presentation/main/MainNavigator.kt b/app/src/main/java/com/spoony/spoony/presentation/main/MainNavigator.kt index 9dc80751..1637407e 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/main/MainNavigator.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/main/MainNavigator.kt @@ -107,7 +107,7 @@ class MainNavigator( locationName: String? = null, scale: String? = null, latitude: String? = null, - longitude: String? = null, + longitude: String? = null ) { navController.navigateToMap( locationId = locationId, diff --git a/app/src/main/java/com/spoony/spoony/presentation/map/MapScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/map/MapScreen.kt index d704d7ca..68860a9f 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/map/MapScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/map/MapScreen.kt @@ -75,7 +75,6 @@ import com.spoony.spoony.presentation.map.model.LocationModel import io.morfly.compose.bottomsheet.material3.rememberBottomSheetScaffoldState import io.morfly.compose.bottomsheet.material3.rememberBottomSheetState import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Composable @@ -159,7 +158,7 @@ fun MapScreen( onBackButtonClick: () -> Unit ) { val sheetState = rememberBottomSheetState( - initialValue = if(placeList.isNotEmpty()) AdvancedSheetState.Collapsed else AdvancedSheetState.PartiallyExpanded, + initialValue = if (placeList.isNotEmpty()) AdvancedSheetState.Collapsed else AdvancedSheetState.PartiallyExpanded, defineValues = { AdvancedSheetState.Collapsed at height(20) AdvancedSheetState.PartiallyExpanded at height(50) diff --git a/app/src/main/java/com/spoony/spoony/presentation/map/MapViewModel.kt b/app/src/main/java/com/spoony/spoony/presentation/map/MapViewModel.kt index 5ee1a232..f8d9ede2 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/map/MapViewModel.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/map/MapViewModel.kt @@ -12,6 +12,7 @@ import com.spoony.spoony.domain.repository.PostRepository import com.spoony.spoony.presentation.map.model.LocationModel import com.spoony.spoony.presentation.map.navigaion.Map import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -19,14 +20,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber -import javax.inject.Inject @HiltViewModel class MapViewModel @Inject constructor( private val postRepository: PostRepository, private val mapRepository: MapRepository, private val authRepository: AuthRepository, - savedStateHandle: SavedStateHandle, + savedStateHandle: SavedStateHandle ) : ViewModel() { private var _state: MutableStateFlow = MutableStateFlow(MapState()) val state: StateFlow @@ -114,14 +114,13 @@ class MapViewModel @Inject constructor( UiState.Success( response.toImmutableList() ) - }, + } ) } }.onFailure(Timber::e) } } - private fun getUserInfo() { viewModelScope.launch { authRepository.getUserInfo(USER_ID) diff --git a/app/src/main/java/com/spoony/spoony/presentation/map/navigaion/MapNavigation.kt b/app/src/main/java/com/spoony/spoony/presentation/map/navigaion/MapNavigation.kt index c4b34224..fb461ffb 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/map/navigaion/MapNavigation.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/map/navigaion/MapNavigation.kt @@ -24,7 +24,8 @@ fun NavController.navigateToMap( scale = scale, latitude = latitude, longitude = longitude - ), navOptions + ), + navOptions ) } diff --git a/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchScreen.kt index a4af8b76..9de5549b 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchScreen.kt @@ -33,7 +33,7 @@ import kotlinx.collections.immutable.ImmutableList fun MapSearchRoute( navigateUp: () -> Unit, navigateToLocationMap: (Int, String, String, String, String) -> Unit, - viewModel: MapSearchViewModel = hiltViewModel(), + viewModel: MapSearchViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -64,7 +64,7 @@ private fun MapSearchScreen( onBackButtonClick: () -> Unit, onDeleteButtonClick: (String) -> Unit, onResultItemClick: (Int, String, String, String, String) -> Unit, - onDeleteAllButtonClick: () -> Unit, + onDeleteAllButtonClick: () -> Unit ) { val focusRequester = remember { FocusRequester() } @@ -118,7 +118,7 @@ private fun MapSearchScreen( .padding(horizontal = 20.dp) ) { items( - items = recentSearchList, + items = recentSearchList ) { searchKeyword -> MapSearchRecentItem( searchText = searchKeyword, diff --git a/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchViewModel.kt b/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchViewModel.kt index 8cfd2eae..d1fd995e 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchViewModel.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchViewModel.kt @@ -6,6 +6,7 @@ import com.spoony.spoony.core.state.UiState import com.spoony.spoony.domain.repository.MapRepository import com.spoony.spoony.presentation.map.model.toModel import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList @@ -14,11 +15,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class MapSearchViewModel @Inject constructor( - private val mapRepository: MapRepository, + private val mapRepository: MapRepository ) : ViewModel() { private var _state: MutableStateFlow = MutableStateFlow(MapSearchState()) val state: StateFlow From d2484cb7b70d2355e7bfb82af8e33ec9c525a844 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 00:04:33 +0900 Subject: [PATCH 05/30] =?UTF-8?q?[MOD/#193]=20=EC=8A=A4=ED=8A=B8=EB=A7=81?= =?UTF-8?q?=20=ED=95=9C=EA=B8=80=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F?= =?UTF-8?q?=20=EC=95=88=EC=A0=95=EC=84=B1=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/network/ContentUriRequestBody.kt | 6 +- .../repositoryimpl/RegisterRepositoryImpl.kt | 133 ++++++++++++------ 2 files changed, 92 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index 73073a28..71c7a34a 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -80,7 +80,7 @@ class ContentUriRequestBody @Inject constructor( runCatching { compressedImage = compressImage(safeUri) }.onFailure { error -> - Timber.e(error, "Image compression failed") + Timber.e(error, "이미지 압축에 실패했습니다.") throw error } } @@ -128,7 +128,7 @@ class ContentUriRequestBody @Inject constructor( BitmapFactory.decodeStream(secondInput, null, options) } } - ) { "Failed to decode bitmap" } + ) { "비트맵 디코딩 실패" } }.let { bitmap -> val orientation = getOrientation(uri) if (orientation != ORIENTATION_NORMAL) { @@ -213,7 +213,7 @@ class ContentUriRequestBody @Inject constructor( } } - Timber.d("Selected quality: $bestQuality for compressed image size: ${bestByteArray.size} bytes") + Timber.d("선택된 품질: $bestQuality, 압축된 이미지 크기: ${bestByteArray.size} 바이트") bestByteArray } diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index 96f76ecf..4cec5ca9 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -3,8 +3,6 @@ package com.spoony.spoony.data.repositoryimpl import android.content.Context import android.net.Uri import android.os.Debug -import androidx.core.graphics.component1 -import androidx.core.graphics.component2 import com.spoony.spoony.core.network.ContentUriRequestBody import com.spoony.spoony.core.network.ContentUriRequestBodyLegacy import com.spoony.spoony.data.datasource.CategoryDataSource @@ -16,6 +14,7 @@ import com.spoony.spoony.domain.entity.CategoryEntity import com.spoony.spoony.domain.entity.PlaceEntity import com.spoony.spoony.domain.repository.RegisterRepository import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.Locale import javax.inject.Inject import kotlin.system.measureTimeMillis import kotlinx.serialization.json.Json @@ -75,7 +74,6 @@ class RegisterRepositoryImpl @Inject constructor( val jsonString = Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto) val requestBody = jsonString.toRequestBody("application/json".toMediaType()) - // 각 이미지에 대해 압축 성능 및 메모리 사용량 평가 수행 val photoParts = photos.map { uri -> testCompressionPerformance(uri) } @@ -89,74 +87,121 @@ class RegisterRepositoryImpl @Inject constructor( /** * 이미지 압축 성능 및 메모리 사용량 평가 함수 * - * - 원본 이미지 크기 (ContentResolver를 통해 측정) - * - 압축 처리 전후의 네이티브 힙 메모리 사용량 측정 (Debug.getNativeHeapAllocatedSize) - * - 압축 후 이미지 크기 (RequestBody의 contentLength()) - * - 실행 시간 측정 + * 측정 항목: + * - 이미지 크기: + * - 원본 크기 (ContentResolver.available()) + * - 압축 후 크기 (RequestBody.contentLength()) * - * 이를 통해 이미지 압축에 사용되는 메모리가 실제 원본 이미지 크기와 비교해 과도하게 할당되는지 확인하여 OOM 위험을 판단할 수 있습니다. + * - 메모리 사용량: + * - Java Heap: Runtime.totalMemory() - freeMemory() + * - Native Heap: Debug.getNativeHeapAllocatedSize() + * - GC 실행 후 측정하여 정확도 향상 + * + * - 성능 지표: + * - 압축 소요 시간 (measureTimeMillis) + * - 압축률 (원본 대비 압축 후 크기) + * + * - OOM 위험도 평가: + * - 메모리 사용량/원본 크기 비율 기반 + * - 낮음: 2배 미만 + * - 중간: 2-3배 + * - 높음: 3배 초과 + * + * 결과는 Timber.d()를 통해 상세 로그로 출력됩니다. + * + * @param uri 압축할 이미지의 Uri + * @return 압축된 이미지가 포함된 MultipartBody.Part */ + private suspend fun testCompressionPerformance(uri: Uri): MultipartBody.Part { val isTestMode = false // true이면 기존 방식, false이면 개선된 방식 - // 1. 원본 이미지 크기 측정 (바이트 단위) - val originalSize: Long = context.contentResolver.openInputStream(uri)?.available()?.toLong() ?: -1L + // 1. 원본 이미지 크기 측정 + val originalSize = context.contentResolver.openInputStream(uri)?.use { + it.available().toLong() + } ?: -1L - // 2. 압축 전 네이티브 힙 메모리 사용량 측정 (바이트 단위) - val nativeHeapBefore: Long = getNativeHeapAllocatedSize() + val beforeMemory = measureMemoryState() - // 3. 이미지 압축 처리 및 실행 시간 측정 + // 3. 이미지 압축 실행 및 시간 측정 lateinit var result: MultipartBody.Part - val elapsedTime = measureTimeMillis { + val compressionTime = measureTimeMillis { result = if (isTestMode) { - // 기존 압축 방식 (예: ContentUriRequestBodyLegacy 사용) ContentUriRequestBodyLegacy(context, uri).toFormData("photos") } else { - // 개선된 압축 방식 (예: ContentUriRequestBody 사용, prepareImage() 호출) ContentUriRequestBody(context, uri).apply { prepareImage() }.toFormData("photos") } } - // 4. 압축 후 네이티브 힙 메모리 사용량 측정 (바이트 단위) - val nativeHeapAfter: Long = getNativeHeapAllocatedSize() - - // 5. 압축에 사용된 네이티브 힙 메모리 (바이트 단위) - val memoryUsedForCompression: Long = nativeHeapAfter - nativeHeapBefore + // 4. 압축 후 메모리 상태 + val afterMemory = measureMemoryState() - // 6. 압축 후 이미지 크기 측정 (RequestBody의 contentLength()) - val compressedSize: Long = result.body.contentLength() + // 5. 압축된 크기 확인 + val compressedSize = result.body.contentLength() - // 7. 로그 출력 (MB 단위로 변환) - val originalSizeMB = originalSize / (1024f * 1024f) - val compressedSizeMB = compressedSize / (1024f * 1024f) - val memoryUsedMB = memoryUsedForCompression / (1024f * 1024f) + // 메모리 사용량 계산 + val memoryDiff = afterMemory - beforeMemory Timber.d( """ - ✨ 이미지 압축 성능 및 메모리 사용량 평가 (${if (isTestMode) "기존" else "개선"} 방식): - 📊 원본 이미지 크기: $originalSizeMB MB - 📉 압축 후 이미지 크기: $compressedSizeMB MB - 📈 압축률: ${"%.2f".format((1 - (compressedSize.toFloat() / originalSize)) * 100)}% - ⏱️ 실행 시간: ${elapsedTime}ms - 💾 압축에 사용된 네이티브 힙 메모리: $memoryUsedMB MB + 📸 이미지 압축 성능 분석 (${if (isTestMode) "기존" else "개선"} 방식): + + 📊 크기 정보: + - 원본: ${originalSize.bytesToMB()} MB + - 압축 후: ${compressedSize.bytesToMB()} MB + - 압축률: ${calculateCompressionRate(originalSize, compressedSize)}% + + 💾 메모리 사용량: + - 압축 전: ${beforeMemory.bytesToMB()} MB + - 압축 후: ${afterMemory.bytesToMB()} MB + - 실제 사용: ${maxOf(0L, memoryDiff).bytesToMB()} MB + + ⚡ 성능: + - 처리 시간: ${compressionTime}ms + - 최대 가용 메모리: ${Runtime.getRuntime().maxMemory().bytesToMB()} MB + + ⚠️ OOM 위험도: ${assessOOMRisk(originalSize, maxOf(0L, memoryDiff))} """.trimIndent() ) - // 원본 이미지 크기와 비교하여, 압축에 사용된 메모리가 과도하다면 경고 로그 출력 (OOM 위험 판단) - if (memoryUsedForCompression > originalSize) { - Timber.w("경고: 압축에 사용된 메모리($memoryUsedMB MB)가 원본 이미지 크기($originalSizeMB MB)보다 큽니다. OOM 위험이 있을 수 있습니다.") - } - return result } - /** - * Debug API를 사용하여 네이티브 힙에 할당된 메모리 크기를 측정합니다. - * 반환 값은 바이트 단위입니다. - */ - private fun getNativeHeapAllocatedSize(): Long { - return Debug.getNativeHeapAllocatedSize() + private fun Long.bytesToMB() = this / (1024.0 * 1024.0) + + private fun calculateCompressionRate(originalSize: Long, compressedSize: Long): String { + return String.format(Locale.US, "%.1f", (1 - compressedSize.toDouble() / originalSize) * 100) + } + + private fun assessOOMRisk(originalSize: Long, usedMemory: Long): String { + val ratio = usedMemory.toDouble() / originalSize.toDouble() + return when { + ratio > 3.0 -> "높음 (메모리 사용량이 원본 대비 3배 초과)" + ratio > 2.0 -> "중간 (메모리 사용량이 원본 대비 2-3배)" + else -> "낮음 (메모리 사용량이 원본 대비 2배 미만)" + } + } + + private fun measureMemoryState(): Long { + var attempt = 0 + var memoryState: Long + + // 최대 3번 시도하며 안정적인 메모리 측정값을 얻음 + do { + System.gc() + Thread.sleep(200) // GC 완료를 위해 대기 시간 증가 + + val javaHeap = Runtime.getRuntime().run { + totalMemory() - freeMemory() + } + val nativeHeap = Debug.getNativeHeapAllocatedSize() + memoryState = javaHeap + nativeHeap + + attempt++ + } while (attempt < 3 && memoryState < 0) + + return memoryState } } From a0ee0dd0e98577c18331c05314e77813a664ec67 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Mon, 10 Feb 2025 15:14:29 +0900 Subject: [PATCH 06/30] =?UTF-8?q?[REF/#198]=20=EA=B0=9C=EC=84=A0=EB=90=9C?= =?UTF-8?q?=20=EC=95=95=EC=B6=95=20=EB=B0=A9=EC=8B=9D=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20=EB=B0=8F=20=EC=84=B1=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryimpl/RegisterRepositoryImpl.kt | 91 ++++++++++++++++++- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index e70b6ee8..c9760e1d 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -2,6 +2,11 @@ package com.spoony.spoony.data.repositoryimpl import android.content.Context import android.net.Uri +import android.os.Debug +import androidx.core.graphics.component1 +import androidx.core.graphics.component2 +import com.spoony.spoony.core.network.ContentUriRequestBody +import com.spoony.spoony.core.network.ContentUriRequestBodyLegacy import com.spoony.spoony.data.datasource.CategoryDataSource import com.spoony.spoony.data.datasource.PlaceDataSource import com.spoony.spoony.data.dto.request.RegisterPostRequestDto @@ -11,10 +16,14 @@ import com.spoony.spoony.domain.entity.CategoryEntity import com.spoony.spoony.domain.entity.PlaceEntity import com.spoony.spoony.domain.repository.RegisterRepository import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody +import timber.log.Timber +import javax.inject.Inject +import kotlin.system.measureTimeMillis class RegisterRepositoryImpl @Inject constructor( private val placeDataSource: PlaceDataSource, @@ -51,7 +60,6 @@ class RegisterRepositoryImpl @Inject constructor( menuList: List, photos: List ): Result = runCatching { - // 1. Request DTO를 RequestBody로 변환 val requestDto = RegisterPostRequestDto( userId = userId, title = title, @@ -68,9 +76,9 @@ class RegisterRepositoryImpl @Inject constructor( val jsonString = Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto) val requestBody = jsonString.toRequestBody("application/json".toMediaType()) - // 2. ContentUriRequestBody를 사용하여 이미지 변환 + // 각 이미지에 대해 압축 성능 및 메모리 사용량 평가 수행 val photoParts = photos.map { uri -> - ContentUriRequestBody(context, uri).toFormData("photos") + testCompressionPerformance(uri) } postService.registerPost( @@ -78,4 +86,79 @@ class RegisterRepositoryImpl @Inject constructor( photos = photoParts ).data } + + /** + * 이미지 압축 성능 및 메모리 사용량 평가 함수 + * + * - 원본 이미지 크기 (ContentResolver를 통해 측정) + * - 압축 처리 전후의 네이티브 힙 메모리 사용량 측정 (Debug.getNativeHeapAllocatedSize) + * - 압축 후 이미지 크기 (RequestBody의 contentLength()) + * - 실행 시간 측정 + * + * 이를 통해 이미지 압축에 사용되는 메모리가 실제 원본 이미지 크기와 비교해 과도하게 할당되는지 확인하여 OOM 위험을 판단할 수 있습니다. + */ + private suspend fun testCompressionPerformance(uri: Uri): MultipartBody.Part { + val isTestMode = false // true이면 기존 방식, false이면 개선된 방식 + + // 1. 원본 이미지 크기 측정 (바이트 단위) + val originalSize: Long = context.contentResolver.openInputStream(uri)?.available()?.toLong() ?: -1L + + // 2. 압축 전 네이티브 힙 메모리 사용량 측정 (바이트 단위) + val nativeHeapBefore: Long = getNativeHeapAllocatedSize() + + // 3. 이미지 압축 처리 및 실행 시간 측정 + lateinit var result: MultipartBody.Part + val elapsedTime = measureTimeMillis { + result = if (isTestMode) { + // 기존 압축 방식 (예: ContentUriRequestBodyLegacy 사용) + ContentUriRequestBodyLegacy(context, uri).toFormData("photos") + } else { + // 개선된 압축 방식 (예: ContentUriRequestBody 사용, prepareImage() 호출) + ContentUriRequestBody(context, uri).apply { + prepareImage() + }.toFormData("photos") + } + } + + // 4. 압축 후 네이티브 힙 메모리 사용량 측정 (바이트 단위) + val nativeHeapAfter: Long = getNativeHeapAllocatedSize() + + // 5. 압축에 사용된 네이티브 힙 메모리 (바이트 단위) + val memoryUsedForCompression: Long = nativeHeapAfter - nativeHeapBefore + + // 6. 압축 후 이미지 크기 측정 (RequestBody의 contentLength()) + val compressedSize: Long = result.body.contentLength() + + // 7. 로그 출력 (MB 단위로 변환) + val originalSizeMB = originalSize / (1024f * 1024f) + val compressedSizeMB = compressedSize / (1024f * 1024f) + val memoryUsedMB = memoryUsedForCompression / (1024f * 1024f) + + Timber.d( + """ + ✨ 이미지 압축 성능 및 메모리 사용량 평가 (${if (isTestMode) "기존" else "개선"} 방식): + 📊 원본 이미지 크기: $originalSizeMB MB + 📉 압축 후 이미지 크기: $compressedSizeMB MB + 📈 압축률: ${"%.2f".format((1 - (compressedSize.toFloat() / originalSize)) * 100)}% + ⏱️ 실행 시간: ${elapsedTime}ms + 💾 압축에 사용된 네이티브 힙 메모리: $memoryUsedMB MB + """.trimIndent() + ) + + // 원본 이미지 크기와 비교하여, 압축에 사용된 메모리가 과도하다면 경고 로그 출력 (OOM 위험 판단) + if (memoryUsedForCompression > originalSize) { + Timber.w("경고: 압축에 사용된 메모리($memoryUsedMB MB)가 원본 이미지 크기($originalSizeMB MB)보다 큽니다. OOM 위험이 있을 수 있습니다.") + } + + return result + } + + /** + * Debug API를 사용하여 네이티브 힙에 할당된 메모리 크기를 측정합니다. + * 반환 값은 바이트 단위입니다. + */ + private fun getNativeHeapAllocatedSize(): Long { + return Debug.getNativeHeapAllocatedSize() + } + } From f035fe3acf192f4d4d309050e9a2f68721be4a2f Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Mon, 10 Feb 2025 15:14:38 +0900 Subject: [PATCH 07/30] =?UTF-8?q?[REF/#198]=20=EA=B0=9C=EC=84=A0=EB=90=9C?= =?UTF-8?q?=20=EC=95=95=EC=B6=95=20=EB=B0=A9=EC=8B=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/network/ContentUriRequestBody.kt | 412 ++++++++++++++++++ .../repositoryimpl/ContentUriRequestBody.kt | 111 ----- 2 files changed, 412 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt delete mode 100644 app/src/main/java/com/spoony/spoony/data/repositoryimpl/ContentUriRequestBody.kt diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt new file mode 100644 index 00000000..e660d01b --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -0,0 +1,412 @@ +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 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 +import java.io.ByteArrayOutputStream +import javax.inject.Inject + +class ContentUriRequestBody @Inject constructor( + context: Context, + private val uri: Uri?, +) : RequestBody() { + private val contentResolver = context.contentResolver + private var compressedImage: ByteArray? = null + private var metadata: ImageMetadata? = null + + private data class ImageMetadata private constructor( + val fileName: String, + val size: Long = 0L, + val mimeType: String? + ) { + companion object { + fun create(fileName: String, size: Long, mimeType: String?) = + ImageMetadata(fileName, size, mimeType) + } + } + + init { + uri?.let { + metadata = extractMetadata(it) + } + } + + private fun extractMetadata(uri: Uri): ImageMetadata { + var fileName = "" + var size = 0L + + contentResolver.query( + uri, + arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + size = cursor.getLong( + cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE) + ) + fileName = cursor.getString( + cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) + ) + } + } + + return ImageMetadata.create( + fileName = fileName, + size = size, + mimeType = contentResolver.getType(uri) + ) + } + + suspend fun prepareImage() = withContext(Dispatchers.IO) { + uri?.let { safeUri -> + runCatching { + compressedImage = compressImage(safeUri) + }.onFailure { error -> + Timber.e(error, "Image compression failed") + throw error + } + } + } + + private suspend fun compressImage(uri: Uri): ByteArray = withContext(Dispatchers.IO) { + val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + loadBitmapWithImageDecoder(uri) + } else { + loadBitmapLegacy(uri) + } + + compressBitmap(bitmap).also { + bitmap.recycle() + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private suspend fun loadBitmapWithImageDecoder(uri: Uri): Bitmap = withContext(Dispatchers.IO) { + val source = ImageDecoder.createSource(contentResolver, uri) + ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.isMutableRequired = true + + val size = calculateTargetSize(info.size.width, info.size.height) + decoder.setTargetSize(size.width, size.height) + } + } + + private suspend fun loadBitmapLegacy(uri: Uri): Bitmap = withContext(Dispatchers.IO) { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + + requireNotNull( + contentResolver.openInputStream(uri)?.use { input -> + BitmapFactory.decodeStream(input, null, options) + options.apply { + inJustDecodeBounds = false + inSampleSize = calculateInSampleSize(this, MAX_WIDTH, MAX_HEIGHT) + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + + contentResolver.openInputStream(uri)?.use { secondInput -> + BitmapFactory.decodeStream(secondInput, null, options) + } + } + ) { "Failed to decode bitmap" } + }?.let { bitmap -> + val orientation = getOrientation(uri) + if (orientation != ORIENTATION_NORMAL) { + rotateBitmap(bitmap, orientation) + } else { + bitmap + } + } ?: throw IllegalStateException("Failed to load bitmap") + + private fun getOrientation(uri: Uri): Int = + contentResolver.query( + uri, + arrayOf(MediaStore.Images.Media.ORIENTATION), + null, + null, + null + )?.use { + if (it.moveToFirst()) { + it.getInt(it.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION)) + } else { + ORIENTATION_NORMAL + } + } ?: getExifOrientation(uri) + + private fun getExifOrientation(uri: Uri): Int = + contentResolver.openInputStream(uri)?.use { input -> + val exif = ExifInterface(input) + when (exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + )) { + 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 = + Bitmap.createBitmap( + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + Matrix().apply { postRotate(angle.toFloat()) }, + true + ).also { + if (it != bitmap) { + bitmap.recycle() + } + } + + private suspend fun compressBitmap(bitmap: Bitmap): ByteArray = withContext(Dispatchers.IO) { + val maxFileSize = MAX_FILE_SIZE_BYTES + var lowerQuality = MIN_QUALITY // 최소 품질 (예: 20) + var upperQuality = INITIAL_QUALITY // 초기 품질 (예: 100) + var bestQuality = lowerQuality // 조건을 만족하는 최고 품질 값 + var bestByteArray: ByteArray = ByteArray(0) + + // 이진 탐색을 통해 파일 크기가 maxFileSize 이하가 되는 최대 품질을 찾음 + while (lowerQuality <= upperQuality) { + val midQuality = (lowerQuality + upperQuality) / 2 + + // 임시 ByteArrayOutputStream에 bitmap을 midQuality로 압축 + val byteArray = ByteArrayOutputStream().use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, midQuality, outputStream) + outputStream.toByteArray() + } + val size = byteArray.size + + if (size <= maxFileSize) { + // 압축 결과가 1MB 이하이면, 더 높은 품질을 시도하기 위해 하한선을 올림 + bestQuality = midQuality + bestByteArray = byteArray + lowerQuality = midQuality + 1 + } else { + // 파일 크기가 너무 크면, 상한선을 낮춤 + upperQuality = midQuality - 1 + } + } + + Timber.d("Selected quality: $bestQuality for compressed image size: ${bestByteArray.size} bytes") + bestByteArray + } + + + private fun calculateInSampleSize( + options: BitmapFactory.Options, + reqWidth: Int, + reqHeight: Int, + ): Int { + 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 + + while (halfHeight / inSampleSize >= reqHeight && + halfWidth / inSampleSize >= reqWidth + ) { + inSampleSize *= 2 + } + } + + return inSampleSize + } + + private fun calculateTargetSize(width: Int, height: Int): Size { + val ratio = width.toFloat() / height.toFloat() + return if (width > height) { + Size(MAX_WIDTH, (MAX_WIDTH / ratio).toInt()) + } else { + Size((MAX_HEIGHT * ratio).toInt(), MAX_HEIGHT) + } + } + + 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 + ) + + private companion object { + // 이미지 크기 관련 + private const val MAX_WIDTH = 1024 + private const val MAX_HEIGHT = 1024 + private const val MAX_FILE_SIZE_BYTES = 1024 * 1024 // 1MB + + // 압축 품질 관련 + private const val INITIAL_QUALITY = 100 + private const val MIN_QUALITY = 20 + private const val QUALITY_DECREMENT = 5 + + // 이미지 회전 관련 + private const val ORIENTATION_NORMAL = 0 + 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" + } +} + +class ContentUriRequestBodyLegacy( + context: Context, + private val uri: Uri?, +) : RequestBody() { + private val contentResolver = context.contentResolver + + private var fileName = "" + private var size = -1L + private var compressedImage: ByteArray? = null + + init { + if (uri != null) { + contentResolver.query( + uri, + arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + size = + cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)) + fileName = + cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) + } + } + + compressBitmap() + } + } + + private fun compressBitmap() { + if (uri != null) { + var originalBitmap: Bitmap + val exif: ExifInterface + + contentResolver.openInputStream(uri).use { inputStream -> + if (inputStream == null) return + val option = BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(this, MAX_WIDTH, MAX_HEIGHT) + } + originalBitmap = BitmapFactory.decodeStream(inputStream, null, option) ?: return + exif = ExifInterface(inputStream) + } + + var orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> orientation = 90 + ExifInterface.ORIENTATION_ROTATE_180 -> orientation = 180 + ExifInterface.ORIENTATION_ROTATE_270 -> orientation = 270 + } + + if (orientation >= 90) { + val matrix = Matrix().apply { + setRotate(orientation.toFloat()) + } + + val rotatedBitmap = Bitmap.createBitmap( + originalBitmap, + 0, + 0, + originalBitmap.width, + originalBitmap.height, + matrix, + true + ) + originalBitmap.recycle() + originalBitmap = rotatedBitmap + } + + val outputStream = ByteArrayOutputStream() + val imageSizeMb = size / (MAX_WIDTH * MAX_HEIGHT.toDouble()) + outputStream.use { + val compressRate = ((IMAGE_SIZE_MB / imageSizeMb) * 100).toInt() + originalBitmap.compress( + Bitmap.CompressFormat.JPEG, + if (imageSizeMb >= IMAGE_SIZE_MB) compressRate else 100, + it, + ) + } + compressedImage = outputStream.toByteArray() + size = compressedImage?.size?.toLong() ?: -1L + } + } + + private fun calculateInSampleSize( + options: BitmapFactory.Options, + reqWidth: Int, + reqHeight: Int, + ): Int { + 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 + + while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 + } + } + + return inSampleSize + } + + private fun getFileName() = fileName + + override fun contentLength(): Long = size + + override fun contentType(): MediaType? = + uri?.let { contentResolver.getType(it)?.toMediaTypeOrNull() } + + override fun writeTo(sink: BufferedSink) { + compressedImage?.let(sink::write) + } + + fun toFormData(name: String) = MultipartBody.Part.createFormData(name, getFileName(), this) + + companion object { + const val IMAGE_SIZE_MB = 1 + const val MAX_WIDTH = 1024 + const val MAX_HEIGHT = 1024 + } +} diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/ContentUriRequestBody.kt deleted file mode 100644 index 0878ad93..00000000 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/ContentUriRequestBody.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.spoony.spoony.data.repositoryimpl - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import android.provider.MediaStore -import java.io.ByteArrayOutputStream -import okhttp3.MediaType -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.MultipartBody -import okhttp3.RequestBody -import okio.BufferedSink - -class ContentUriRequestBody( - context: Context, - private val uri: Uri? -) : RequestBody() { - private val contentResolver = context.contentResolver - - private var fileName = "" - private var size = -1L - private var compressedImage: ByteArray? = null - - init { - if (uri != null) { - contentResolver.query( - uri, - arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), - null, - null, - null - )?.use { cursor -> - if (cursor.moveToFirst()) { - size = - cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)) - fileName = - cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) - } - } - - compressBitmap() - } - } - - private fun compressBitmap() { - if (uri != null) { - var originalBitmap: Bitmap - - contentResolver.openInputStream(uri).use { inputStream -> - if (inputStream == null) return - val option = BitmapFactory.Options().apply { - inSampleSize = calculateInSampleSize(this, MAX_WIDTH, MAX_HEIGHT) - } - originalBitmap = BitmapFactory.decodeStream(inputStream, null, option) ?: return - } - - val outputStream = ByteArrayOutputStream() - val imageSizeMb = size / (MAX_WIDTH * MAX_HEIGHT.toDouble()) - outputStream.use { - val compressRate = ((IMAGE_SIZE_MB / imageSizeMb) * 100).toInt() - originalBitmap.compress( - Bitmap.CompressFormat.JPEG, - if (imageSizeMb >= IMAGE_SIZE_MB) compressRate else 100, - it - ) - } - compressedImage = outputStream.toByteArray() - size = compressedImage?.size?.toLong() ?: -1L - } - } - - private fun calculateInSampleSize( - options: BitmapFactory.Options, - reqWidth: Int, - reqHeight: Int - ): Int { - 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 - - while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { - inSampleSize *= 2 - } - } - - return inSampleSize - } - - private fun getFileName() = fileName - - override fun contentLength(): Long = size - - override fun contentType(): MediaType? = - uri?.let { contentResolver.getType(it)?.toMediaTypeOrNull() } - - override fun writeTo(sink: BufferedSink) { - compressedImage?.let(sink::write) - } - - fun toFormData(name: String) = MultipartBody.Part.createFormData(name, getFileName(), this) - - companion object { - const val IMAGE_SIZE_MB = 1 - const val MAX_WIDTH = 1024 - const val MAX_HEIGHT = 1024 - } -} From 59622bce650ba7d2fb861eb27897c55d26f88aad Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Mon, 10 Feb 2025 16:34:37 +0900 Subject: [PATCH 08/30] =?UTF-8?q?[MOD/#198]=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spoony/spoony/core/network/ContentUriRequestBody.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index e660d01b..b06e5915 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -129,14 +129,14 @@ class ContentUriRequestBody @Inject constructor( } } ) { "Failed to decode bitmap" } - }?.let { bitmap -> + }.let { bitmap -> val orientation = getOrientation(uri) if (orientation != ORIENTATION_NORMAL) { rotateBitmap(bitmap, orientation) } else { bitmap } - } ?: throw IllegalStateException("Failed to load bitmap") + } private fun getOrientation(uri: Uri): Int = contentResolver.query( @@ -187,7 +187,7 @@ class ContentUriRequestBody @Inject constructor( var lowerQuality = MIN_QUALITY // 최소 품질 (예: 20) var upperQuality = INITIAL_QUALITY // 초기 품질 (예: 100) var bestQuality = lowerQuality // 조건을 만족하는 최고 품질 값 - var bestByteArray: ByteArray = ByteArray(0) + var bestByteArray = ByteArray(0) // 이진 탐색을 통해 파일 크기가 maxFileSize 이하가 되는 최대 품질을 찾음 while (lowerQuality <= upperQuality) { @@ -270,7 +270,6 @@ class ContentUriRequestBody @Inject constructor( // 압축 품질 관련 private const val INITIAL_QUALITY = 100 private const val MIN_QUALITY = 20 - private const val QUALITY_DECREMENT = 5 // 이미지 회전 관련 private const val ORIENTATION_NORMAL = 0 From 32f04f241534cbeb37ded5cfd4ce2a34d0e97278 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Mon, 10 Feb 2025 16:37:07 +0900 Subject: [PATCH 09/30] =?UTF-8?q?[MOD/#198]=20lint=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spoony/spoony/core/database/SearchDao.kt | 1 - .../core/network/ContentUriRequestBody.kt | 35 ++++++++++--------- .../data/repositoryimpl/MapRepositoryImpl.kt | 3 +- .../repositoryimpl/RegisterRepositoryImpl.kt | 6 ++-- .../spoony/presentation/main/MainNavigator.kt | 2 +- .../spoony/presentation/map/MapScreen.kt | 3 +- .../spoony/presentation/map/MapViewModel.kt | 7 ++-- .../map/navigaion/MapNavigation.kt | 3 +- .../map/search/MapSearchScreen.kt | 6 ++-- .../map/search/MapSearchViewModel.kt | 4 +-- 10 files changed, 33 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/database/SearchDao.kt b/app/src/main/java/com/spoony/spoony/core/database/SearchDao.kt index 5d9dc363..bdae0a3b 100644 --- a/app/src/main/java/com/spoony/spoony/core/database/SearchDao.kt +++ b/app/src/main/java/com/spoony/spoony/core/database/SearchDao.kt @@ -2,7 +2,6 @@ package com.spoony.spoony.core.database import androidx.room.Dao import androidx.room.Insert -import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import com.spoony.spoony.core.database.entity.SearchEntity diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index b06e5915..73073a28 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -11,6 +11,8 @@ import android.os.Build import android.provider.MediaStore import android.util.Size import androidx.annotation.RequiresApi +import java.io.ByteArrayOutputStream +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType @@ -19,12 +21,10 @@ import okhttp3.MultipartBody import okhttp3.RequestBody import okio.BufferedSink import timber.log.Timber -import java.io.ByteArrayOutputStream -import javax.inject.Inject class ContentUriRequestBody @Inject constructor( context: Context, - private val uri: Uri?, + private val uri: Uri? ) : RequestBody() { private val contentResolver = context.contentResolver private var compressedImage: ByteArray? = null @@ -56,7 +56,7 @@ class ContentUriRequestBody @Inject constructor( arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), null, null, - null, + null )?.use { cursor -> if (cursor.moveToFirst()) { size = cursor.getLong( @@ -156,10 +156,12 @@ class ContentUriRequestBody @Inject constructor( private fun getExifOrientation(uri: Uri): Int = contentResolver.openInputStream(uri)?.use { input -> val exif = ExifInterface(input) - when (exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL - )) { + when ( + exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + ) { ExifInterface.ORIENTATION_ROTATE_90 -> ORIENTATION_ROTATE_90 ExifInterface.ORIENTATION_ROTATE_180 -> ORIENTATION_ROTATE_180 ExifInterface.ORIENTATION_ROTATE_270 -> ORIENTATION_ROTATE_270 @@ -184,9 +186,9 @@ class ContentUriRequestBody @Inject constructor( private suspend fun compressBitmap(bitmap: Bitmap): ByteArray = withContext(Dispatchers.IO) { val maxFileSize = MAX_FILE_SIZE_BYTES - var lowerQuality = MIN_QUALITY // 최소 품질 (예: 20) - var upperQuality = INITIAL_QUALITY // 초기 품질 (예: 100) - var bestQuality = lowerQuality // 조건을 만족하는 최고 품질 값 + var lowerQuality = MIN_QUALITY // 최소 품질 (예: 20) + var upperQuality = INITIAL_QUALITY // 초기 품질 (예: 100) + var bestQuality = lowerQuality // 조건을 만족하는 최고 품질 값 var bestByteArray = ByteArray(0) // 이진 탐색을 통해 파일 크기가 maxFileSize 이하가 되는 최대 품질을 찾음 @@ -215,11 +217,10 @@ class ContentUriRequestBody @Inject constructor( bestByteArray } - private fun calculateInSampleSize( options: BitmapFactory.Options, reqWidth: Int, - reqHeight: Int, + reqHeight: Int ): Int { val (height: Int, width: Int) = options.run { outHeight to outWidth } var inSampleSize = 1 @@ -284,7 +285,7 @@ class ContentUriRequestBody @Inject constructor( class ContentUriRequestBodyLegacy( context: Context, - private val uri: Uri?, + private val uri: Uri? ) : RequestBody() { private val contentResolver = context.contentResolver @@ -299,7 +300,7 @@ class ContentUriRequestBodyLegacy( arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), null, null, - null, + null )?.use { cursor -> if (cursor.moveToFirst()) { size = @@ -362,7 +363,7 @@ class ContentUriRequestBodyLegacy( originalBitmap.compress( Bitmap.CompressFormat.JPEG, if (imageSizeMb >= IMAGE_SIZE_MB) compressRate else 100, - it, + it ) } compressedImage = outputStream.toByteArray() @@ -373,7 +374,7 @@ class ContentUriRequestBodyLegacy( private fun calculateInSampleSize( options: BitmapFactory.Options, reqWidth: Int, - reqHeight: Int, + reqHeight: Int ): Int { val (height: Int, width: Int) = options.run { outHeight to outWidth } var inSampleSize = 1 diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/MapRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/MapRepositoryImpl.kt index d88fa77d..6726ae1f 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/MapRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/MapRepositoryImpl.kt @@ -1,7 +1,6 @@ package com.spoony.spoony.data.repositoryimpl import com.spoony.spoony.core.database.SearchDao -import com.spoony.spoony.core.database.entity.SearchEntity import com.spoony.spoony.data.datasource.MapRemoteDataSource import com.spoony.spoony.data.datasource.PostRemoteDataSource import com.spoony.spoony.data.mapper.toDomain @@ -14,7 +13,7 @@ import javax.inject.Inject class MapRepositoryImpl @Inject constructor( private val mapRemoteDataSource: MapRemoteDataSource, private val postRemoteDataSource: PostRemoteDataSource, - private val searchDao: SearchDao, + private val searchDao: SearchDao ) : MapRepository { override suspend fun searchLocation(query: String): Result> = runCatching { diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index c9760e1d..96f76ecf 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -16,14 +16,13 @@ import com.spoony.spoony.domain.entity.CategoryEntity import com.spoony.spoony.domain.entity.PlaceEntity import com.spoony.spoony.domain.repository.RegisterRepository import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlin.system.measureTimeMillis import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType -import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody import timber.log.Timber -import javax.inject.Inject -import kotlin.system.measureTimeMillis class RegisterRepositoryImpl @Inject constructor( private val placeDataSource: PlaceDataSource, @@ -160,5 +159,4 @@ class RegisterRepositoryImpl @Inject constructor( private fun getNativeHeapAllocatedSize(): Long { return Debug.getNativeHeapAllocatedSize() } - } diff --git a/app/src/main/java/com/spoony/spoony/presentation/main/MainNavigator.kt b/app/src/main/java/com/spoony/spoony/presentation/main/MainNavigator.kt index 9dc80751..1637407e 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/main/MainNavigator.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/main/MainNavigator.kt @@ -107,7 +107,7 @@ class MainNavigator( locationName: String? = null, scale: String? = null, latitude: String? = null, - longitude: String? = null, + longitude: String? = null ) { navController.navigateToMap( locationId = locationId, diff --git a/app/src/main/java/com/spoony/spoony/presentation/map/MapScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/map/MapScreen.kt index d704d7ca..68860a9f 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/map/MapScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/map/MapScreen.kt @@ -75,7 +75,6 @@ import com.spoony.spoony.presentation.map.model.LocationModel import io.morfly.compose.bottomsheet.material3.rememberBottomSheetScaffoldState import io.morfly.compose.bottomsheet.material3.rememberBottomSheetState import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Composable @@ -159,7 +158,7 @@ fun MapScreen( onBackButtonClick: () -> Unit ) { val sheetState = rememberBottomSheetState( - initialValue = if(placeList.isNotEmpty()) AdvancedSheetState.Collapsed else AdvancedSheetState.PartiallyExpanded, + initialValue = if (placeList.isNotEmpty()) AdvancedSheetState.Collapsed else AdvancedSheetState.PartiallyExpanded, defineValues = { AdvancedSheetState.Collapsed at height(20) AdvancedSheetState.PartiallyExpanded at height(50) diff --git a/app/src/main/java/com/spoony/spoony/presentation/map/MapViewModel.kt b/app/src/main/java/com/spoony/spoony/presentation/map/MapViewModel.kt index 5ee1a232..f8d9ede2 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/map/MapViewModel.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/map/MapViewModel.kt @@ -12,6 +12,7 @@ import com.spoony.spoony.domain.repository.PostRepository import com.spoony.spoony.presentation.map.model.LocationModel import com.spoony.spoony.presentation.map.navigaion.Map import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -19,14 +20,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber -import javax.inject.Inject @HiltViewModel class MapViewModel @Inject constructor( private val postRepository: PostRepository, private val mapRepository: MapRepository, private val authRepository: AuthRepository, - savedStateHandle: SavedStateHandle, + savedStateHandle: SavedStateHandle ) : ViewModel() { private var _state: MutableStateFlow = MutableStateFlow(MapState()) val state: StateFlow @@ -114,14 +114,13 @@ class MapViewModel @Inject constructor( UiState.Success( response.toImmutableList() ) - }, + } ) } }.onFailure(Timber::e) } } - private fun getUserInfo() { viewModelScope.launch { authRepository.getUserInfo(USER_ID) diff --git a/app/src/main/java/com/spoony/spoony/presentation/map/navigaion/MapNavigation.kt b/app/src/main/java/com/spoony/spoony/presentation/map/navigaion/MapNavigation.kt index c4b34224..fb461ffb 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/map/navigaion/MapNavigation.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/map/navigaion/MapNavigation.kt @@ -24,7 +24,8 @@ fun NavController.navigateToMap( scale = scale, latitude = latitude, longitude = longitude - ), navOptions + ), + navOptions ) } diff --git a/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchScreen.kt index a4af8b76..9de5549b 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchScreen.kt @@ -33,7 +33,7 @@ import kotlinx.collections.immutable.ImmutableList fun MapSearchRoute( navigateUp: () -> Unit, navigateToLocationMap: (Int, String, String, String, String) -> Unit, - viewModel: MapSearchViewModel = hiltViewModel(), + viewModel: MapSearchViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -64,7 +64,7 @@ private fun MapSearchScreen( onBackButtonClick: () -> Unit, onDeleteButtonClick: (String) -> Unit, onResultItemClick: (Int, String, String, String, String) -> Unit, - onDeleteAllButtonClick: () -> Unit, + onDeleteAllButtonClick: () -> Unit ) { val focusRequester = remember { FocusRequester() } @@ -118,7 +118,7 @@ private fun MapSearchScreen( .padding(horizontal = 20.dp) ) { items( - items = recentSearchList, + items = recentSearchList ) { searchKeyword -> MapSearchRecentItem( searchText = searchKeyword, diff --git a/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchViewModel.kt b/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchViewModel.kt index 8cfd2eae..d1fd995e 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchViewModel.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/map/search/MapSearchViewModel.kt @@ -6,6 +6,7 @@ import com.spoony.spoony.core.state.UiState import com.spoony.spoony.domain.repository.MapRepository import com.spoony.spoony.presentation.map.model.toModel import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList @@ -14,11 +15,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class MapSearchViewModel @Inject constructor( - private val mapRepository: MapRepository, + private val mapRepository: MapRepository ) : ViewModel() { private var _state: MutableStateFlow = MutableStateFlow(MapSearchState()) val state: StateFlow From 93ea73dec5a93c86556014d7c4a4dc158e29651f Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 00:04:33 +0900 Subject: [PATCH 10/30] =?UTF-8?q?[MOD/#198]=20=EC=8A=A4=ED=8A=B8=EB=A7=81?= =?UTF-8?q?=20=ED=95=9C=EA=B8=80=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F?= =?UTF-8?q?=20=EC=95=88=EC=A0=95=EC=84=B1=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/network/ContentUriRequestBody.kt | 6 +- .../repositoryimpl/RegisterRepositoryImpl.kt | 133 ++++++++++++------ 2 files changed, 92 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index 73073a28..71c7a34a 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -80,7 +80,7 @@ class ContentUriRequestBody @Inject constructor( runCatching { compressedImage = compressImage(safeUri) }.onFailure { error -> - Timber.e(error, "Image compression failed") + Timber.e(error, "이미지 압축에 실패했습니다.") throw error } } @@ -128,7 +128,7 @@ class ContentUriRequestBody @Inject constructor( BitmapFactory.decodeStream(secondInput, null, options) } } - ) { "Failed to decode bitmap" } + ) { "비트맵 디코딩 실패" } }.let { bitmap -> val orientation = getOrientation(uri) if (orientation != ORIENTATION_NORMAL) { @@ -213,7 +213,7 @@ class ContentUriRequestBody @Inject constructor( } } - Timber.d("Selected quality: $bestQuality for compressed image size: ${bestByteArray.size} bytes") + Timber.d("선택된 품질: $bestQuality, 압축된 이미지 크기: ${bestByteArray.size} 바이트") bestByteArray } diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index 96f76ecf..4cec5ca9 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -3,8 +3,6 @@ package com.spoony.spoony.data.repositoryimpl import android.content.Context import android.net.Uri import android.os.Debug -import androidx.core.graphics.component1 -import androidx.core.graphics.component2 import com.spoony.spoony.core.network.ContentUriRequestBody import com.spoony.spoony.core.network.ContentUriRequestBodyLegacy import com.spoony.spoony.data.datasource.CategoryDataSource @@ -16,6 +14,7 @@ import com.spoony.spoony.domain.entity.CategoryEntity import com.spoony.spoony.domain.entity.PlaceEntity import com.spoony.spoony.domain.repository.RegisterRepository import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.Locale import javax.inject.Inject import kotlin.system.measureTimeMillis import kotlinx.serialization.json.Json @@ -75,7 +74,6 @@ class RegisterRepositoryImpl @Inject constructor( val jsonString = Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto) val requestBody = jsonString.toRequestBody("application/json".toMediaType()) - // 각 이미지에 대해 압축 성능 및 메모리 사용량 평가 수행 val photoParts = photos.map { uri -> testCompressionPerformance(uri) } @@ -89,74 +87,121 @@ class RegisterRepositoryImpl @Inject constructor( /** * 이미지 압축 성능 및 메모리 사용량 평가 함수 * - * - 원본 이미지 크기 (ContentResolver를 통해 측정) - * - 압축 처리 전후의 네이티브 힙 메모리 사용량 측정 (Debug.getNativeHeapAllocatedSize) - * - 압축 후 이미지 크기 (RequestBody의 contentLength()) - * - 실행 시간 측정 + * 측정 항목: + * - 이미지 크기: + * - 원본 크기 (ContentResolver.available()) + * - 압축 후 크기 (RequestBody.contentLength()) * - * 이를 통해 이미지 압축에 사용되는 메모리가 실제 원본 이미지 크기와 비교해 과도하게 할당되는지 확인하여 OOM 위험을 판단할 수 있습니다. + * - 메모리 사용량: + * - Java Heap: Runtime.totalMemory() - freeMemory() + * - Native Heap: Debug.getNativeHeapAllocatedSize() + * - GC 실행 후 측정하여 정확도 향상 + * + * - 성능 지표: + * - 압축 소요 시간 (measureTimeMillis) + * - 압축률 (원본 대비 압축 후 크기) + * + * - OOM 위험도 평가: + * - 메모리 사용량/원본 크기 비율 기반 + * - 낮음: 2배 미만 + * - 중간: 2-3배 + * - 높음: 3배 초과 + * + * 결과는 Timber.d()를 통해 상세 로그로 출력됩니다. + * + * @param uri 압축할 이미지의 Uri + * @return 압축된 이미지가 포함된 MultipartBody.Part */ + private suspend fun testCompressionPerformance(uri: Uri): MultipartBody.Part { val isTestMode = false // true이면 기존 방식, false이면 개선된 방식 - // 1. 원본 이미지 크기 측정 (바이트 단위) - val originalSize: Long = context.contentResolver.openInputStream(uri)?.available()?.toLong() ?: -1L + // 1. 원본 이미지 크기 측정 + val originalSize = context.contentResolver.openInputStream(uri)?.use { + it.available().toLong() + } ?: -1L - // 2. 압축 전 네이티브 힙 메모리 사용량 측정 (바이트 단위) - val nativeHeapBefore: Long = getNativeHeapAllocatedSize() + val beforeMemory = measureMemoryState() - // 3. 이미지 압축 처리 및 실행 시간 측정 + // 3. 이미지 압축 실행 및 시간 측정 lateinit var result: MultipartBody.Part - val elapsedTime = measureTimeMillis { + val compressionTime = measureTimeMillis { result = if (isTestMode) { - // 기존 압축 방식 (예: ContentUriRequestBodyLegacy 사용) ContentUriRequestBodyLegacy(context, uri).toFormData("photos") } else { - // 개선된 압축 방식 (예: ContentUriRequestBody 사용, prepareImage() 호출) ContentUriRequestBody(context, uri).apply { prepareImage() }.toFormData("photos") } } - // 4. 압축 후 네이티브 힙 메모리 사용량 측정 (바이트 단위) - val nativeHeapAfter: Long = getNativeHeapAllocatedSize() - - // 5. 압축에 사용된 네이티브 힙 메모리 (바이트 단위) - val memoryUsedForCompression: Long = nativeHeapAfter - nativeHeapBefore + // 4. 압축 후 메모리 상태 + val afterMemory = measureMemoryState() - // 6. 압축 후 이미지 크기 측정 (RequestBody의 contentLength()) - val compressedSize: Long = result.body.contentLength() + // 5. 압축된 크기 확인 + val compressedSize = result.body.contentLength() - // 7. 로그 출력 (MB 단위로 변환) - val originalSizeMB = originalSize / (1024f * 1024f) - val compressedSizeMB = compressedSize / (1024f * 1024f) - val memoryUsedMB = memoryUsedForCompression / (1024f * 1024f) + // 메모리 사용량 계산 + val memoryDiff = afterMemory - beforeMemory Timber.d( """ - ✨ 이미지 압축 성능 및 메모리 사용량 평가 (${if (isTestMode) "기존" else "개선"} 방식): - 📊 원본 이미지 크기: $originalSizeMB MB - 📉 압축 후 이미지 크기: $compressedSizeMB MB - 📈 압축률: ${"%.2f".format((1 - (compressedSize.toFloat() / originalSize)) * 100)}% - ⏱️ 실행 시간: ${elapsedTime}ms - 💾 압축에 사용된 네이티브 힙 메모리: $memoryUsedMB MB + 📸 이미지 압축 성능 분석 (${if (isTestMode) "기존" else "개선"} 방식): + + 📊 크기 정보: + - 원본: ${originalSize.bytesToMB()} MB + - 압축 후: ${compressedSize.bytesToMB()} MB + - 압축률: ${calculateCompressionRate(originalSize, compressedSize)}% + + 💾 메모리 사용량: + - 압축 전: ${beforeMemory.bytesToMB()} MB + - 압축 후: ${afterMemory.bytesToMB()} MB + - 실제 사용: ${maxOf(0L, memoryDiff).bytesToMB()} MB + + ⚡ 성능: + - 처리 시간: ${compressionTime}ms + - 최대 가용 메모리: ${Runtime.getRuntime().maxMemory().bytesToMB()} MB + + ⚠️ OOM 위험도: ${assessOOMRisk(originalSize, maxOf(0L, memoryDiff))} """.trimIndent() ) - // 원본 이미지 크기와 비교하여, 압축에 사용된 메모리가 과도하다면 경고 로그 출력 (OOM 위험 판단) - if (memoryUsedForCompression > originalSize) { - Timber.w("경고: 압축에 사용된 메모리($memoryUsedMB MB)가 원본 이미지 크기($originalSizeMB MB)보다 큽니다. OOM 위험이 있을 수 있습니다.") - } - return result } - /** - * Debug API를 사용하여 네이티브 힙에 할당된 메모리 크기를 측정합니다. - * 반환 값은 바이트 단위입니다. - */ - private fun getNativeHeapAllocatedSize(): Long { - return Debug.getNativeHeapAllocatedSize() + private fun Long.bytesToMB() = this / (1024.0 * 1024.0) + + private fun calculateCompressionRate(originalSize: Long, compressedSize: Long): String { + return String.format(Locale.US, "%.1f", (1 - compressedSize.toDouble() / originalSize) * 100) + } + + private fun assessOOMRisk(originalSize: Long, usedMemory: Long): String { + val ratio = usedMemory.toDouble() / originalSize.toDouble() + return when { + ratio > 3.0 -> "높음 (메모리 사용량이 원본 대비 3배 초과)" + ratio > 2.0 -> "중간 (메모리 사용량이 원본 대비 2-3배)" + else -> "낮음 (메모리 사용량이 원본 대비 2배 미만)" + } + } + + private fun measureMemoryState(): Long { + var attempt = 0 + var memoryState: Long + + // 최대 3번 시도하며 안정적인 메모리 측정값을 얻음 + do { + System.gc() + Thread.sleep(200) // GC 완료를 위해 대기 시간 증가 + + val javaHeap = Runtime.getRuntime().run { + totalMemory() - freeMemory() + } + val nativeHeap = Debug.getNativeHeapAllocatedSize() + memoryState = javaHeap + nativeHeap + + attempt++ + } while (attempt < 3 && memoryState < 0) + + return memoryState } } From 1ea3d847df79ff1d93565dc57e4e60d9c9c1a92f Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 00:45:36 +0900 Subject: [PATCH 11/30] =?UTF-8?q?[MOD/#198]=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryimpl/RegisterRepositoryImpl.kt | 190 ++++++++++++------ 1 file changed, 127 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index 4cec5ca9..4070cb42 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -116,92 +116,156 @@ class RegisterRepositoryImpl @Inject constructor( private suspend fun testCompressionPerformance(uri: Uri): MultipartBody.Part { val isTestMode = false // true이면 기존 방식, false이면 개선된 방식 - // 1. 원본 이미지 크기 측정 - val originalSize = context.contentResolver.openInputStream(uri)?.use { - it.available().toLong() - } ?: -1L + // 1. 원본 이미지 크기 측정 (바이트 단위) + val originalSize: Long = context.contentResolver.openInputStream(uri)?.available()?.toLong() ?: -1L - val beforeMemory = measureMemoryState() + // 2. 압축 전 네이티브 힙 메모리 사용량 측정 (바이트 단위) + val nativeHeapBefore: Long = getNativeHeapAllocatedSize() - // 3. 이미지 압축 실행 및 시간 측정 + // 3. 이미지 압축 처리 및 실행 시간 측정 lateinit var result: MultipartBody.Part - val compressionTime = measureTimeMillis { + val elapsedTime = measureTimeMillis { result = if (isTestMode) { + // 기존 압축 방식 (예: ContentUriRequestBodyLegacy 사용) ContentUriRequestBodyLegacy(context, uri).toFormData("photos") } else { + // 개선된 압축 방식 (예: ContentUriRequestBody 사용, prepareImage() 호출) ContentUriRequestBody(context, uri).apply { prepareImage() }.toFormData("photos") } } - // 4. 압축 후 메모리 상태 - val afterMemory = measureMemoryState() + // 4. 압축 후 네이티브 힙 메모리 사용량 측정 (바이트 단위) + val nativeHeapAfter: Long = getNativeHeapAllocatedSize() - // 5. 압축된 크기 확인 - val compressedSize = result.body.contentLength() + // 5. 압축에 사용된 네이티브 힙 메모리 (바이트 단위) + val memoryUsedForCompression: Long = nativeHeapAfter - nativeHeapBefore - // 메모리 사용량 계산 - val memoryDiff = afterMemory - beforeMemory + // 6. 압축 후 이미지 크기 측정 (RequestBody의 contentLength()) + val compressedSize: Long = result.body.contentLength() + + // 7. 로그 출력 (MB 단위로 변환) + val originalSizeMB = originalSize / (1024f * 1024f) + val compressedSizeMB = compressedSize / (1024f * 1024f) + val memoryUsedMB = memoryUsedForCompression / (1024f * 1024f) Timber.d( """ - 📸 이미지 압축 성능 분석 (${if (isTestMode) "기존" else "개선"} 방식): - - 📊 크기 정보: - - 원본: ${originalSize.bytesToMB()} MB - - 압축 후: ${compressedSize.bytesToMB()} MB - - 압축률: ${calculateCompressionRate(originalSize, compressedSize)}% - - 💾 메모리 사용량: - - 압축 전: ${beforeMemory.bytesToMB()} MB - - 압축 후: ${afterMemory.bytesToMB()} MB - - 실제 사용: ${maxOf(0L, memoryDiff).bytesToMB()} MB - - ⚡ 성능: - - 처리 시간: ${compressionTime}ms - - 최대 가용 메모리: ${Runtime.getRuntime().maxMemory().bytesToMB()} MB - - ⚠️ OOM 위험도: ${assessOOMRisk(originalSize, maxOf(0L, memoryDiff))} + ✨ 이미지 압축 성능 및 메모리 사용량 평가 (${if (isTestMode) "기존" else "개선"} 방식): + 📊 원본 이미지 크기: $originalSizeMB MB + 📉 압축 후 이미지 크기: $compressedSizeMB MB + 📈 압축률: ${"%.2f".format((1 - (compressedSize.toFloat() / originalSize)) * 100)}% + ⏱️ 실행 시간: ${elapsedTime}ms + 💾 압축에 사용된 네이티브 힙 메모리: $memoryUsedMB MB """.trimIndent() ) - return result - } - - private fun Long.bytesToMB() = this / (1024.0 * 1024.0) + // 원본 이미지 크기와 비교하여, 압축에 사용된 메모리가 과도하다면 경고 로그 출력 (OOM 위험 판단) + if (memoryUsedForCompression > originalSize) { + Timber.w("경고: 압축에 사용된 메모리($memoryUsedMB MB)가 원본 이미지 크기($originalSizeMB MB)보다 큽니다. OOM 위험이 있을 수 있습니다.") + } - private fun calculateCompressionRate(originalSize: Long, compressedSize: Long): String { - return String.format(Locale.US, "%.1f", (1 - compressedSize.toDouble() / originalSize) * 100) + return result } - private fun assessOOMRisk(originalSize: Long, usedMemory: Long): String { - val ratio = usedMemory.toDouble() / originalSize.toDouble() - return when { - ratio > 3.0 -> "높음 (메모리 사용량이 원본 대비 3배 초과)" - ratio > 2.0 -> "중간 (메모리 사용량이 원본 대비 2-3배)" - else -> "낮음 (메모리 사용량이 원본 대비 2배 미만)" - } + /** + * Debug API를 사용하여 네이티브 힙에 할당된 메모리 크기를 측정합니다. + * 반환 값은 바이트 단위입니다. + */ + private fun getNativeHeapAllocatedSize(): Long { + return Debug.getNativeHeapAllocatedSize() } - private fun measureMemoryState(): Long { - var attempt = 0 - var memoryState: Long - - // 최대 3번 시도하며 안정적인 메모리 측정값을 얻음 - do { - System.gc() - Thread.sleep(200) // GC 완료를 위해 대기 시간 증가 - - val javaHeap = Runtime.getRuntime().run { - totalMemory() - freeMemory() - } - val nativeHeap = Debug.getNativeHeapAllocatedSize() - memoryState = javaHeap + nativeHeap - - attempt++ - } while (attempt < 3 && memoryState < 0) - - return memoryState - } +// private suspend fun testCompressionPerformance(uri: Uri): MultipartBody.Part { +// val isTestMode = false // true이면 기존 방식, false이면 개선된 방식 +// +// // 1. 원본 이미지 크기 측정 +// val originalSize = context.contentResolver.openInputStream(uri)?.use { +// it.available().toLong() +// } ?: -1L +// +// val beforeMemory = measureMemoryState() +// +// // 3. 이미지 압축 실행 및 시간 측정 +// lateinit var result: MultipartBody.Part +// val compressionTime = measureTimeMillis { +// result = if (isTestMode) { +// ContentUriRequestBodyLegacy(context, uri).toFormData("photos") +// } else { +// ContentUriRequestBody(context, uri).apply { +// prepareImage() +// }.toFormData("photos") +// } +// } +// +// // 4. 압축 후 메모리 상태 +// val afterMemory = measureMemoryState() +// +// // 5. 압축된 크기 확인 +// val compressedSize = result.body.contentLength() +// +// // 메모리 사용량 계산 +// val memoryDiff = afterMemory - beforeMemory +// +// Timber.d( +// """ +// 📸 이미지 압축 성능 분석 (${if (isTestMode) "기존" else "개선"} 방식): +// +// 📊 크기 정보: +// - 원본: ${originalSize.bytesToMB()} MB +// - 압축 후: ${compressedSize.bytesToMB()} MB +// - 압축률: ${calculateCompressionRate(originalSize, compressedSize)}% +// +// 💾 메모리 사용량: +// - 압축 전: ${beforeMemory.bytesToMB()} MB +// - 압축 후: ${afterMemory.bytesToMB()} MB +// - 실제 사용: ${maxOf(0L, memoryDiff).bytesToMB()} MB +// +// ⚡ 성능: +// - 처리 시간: ${compressionTime}ms +// - 최대 가용 메모리: ${Runtime.getRuntime().maxMemory().bytesToMB()} MB +// +// ⚠️ OOM 위험도: ${assessOOMRisk(originalSize, maxOf(0L, memoryDiff))} +// """.trimIndent() +// ) +// +// return result +// } +// +// private fun Long.bytesToMB() = this / (1024.0 * 1024.0) +// +// private fun calculateCompressionRate(originalSize: Long, compressedSize: Long): String { +// return String.format(Locale.US, "%.1f", (1 - compressedSize.toDouble() / originalSize) * 100) +// } +// +// private fun assessOOMRisk(originalSize: Long, usedMemory: Long): String { +// val ratio = usedMemory.toDouble() / originalSize.toDouble() +// return when { +// ratio > 3.0 -> "높음 (메모리 사용량이 원본 대비 3배 초과)" +// ratio > 2.0 -> "중간 (메모리 사용량이 원본 대비 2-3배)" +// else -> "낮음 (메모리 사용량이 원본 대비 2배 미만)" +// } +// } +// +// private fun measureMemoryState(): Long { +// var attempt = 0 +// var memoryState: Long +// +// // 최대 3번 시도하며 안정적인 메모리 측정값을 얻음 +// do { +// System.gc() +// Thread.sleep(200) // GC 완료를 위해 대기 시간 증가 +// +// val javaHeap = Runtime.getRuntime().run { +// totalMemory() - freeMemory() +// } +// val nativeHeap = Debug.getNativeHeapAllocatedSize() +// memoryState = javaHeap + nativeHeap +// +// attempt++ +// } while (attempt < 3 && memoryState < 0) +// +// return memoryState +// } } From 7d8cc4a662242ffcf713d97579d9f385b0dc6938 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 01:17:51 +0900 Subject: [PATCH 12/30] =?UTF-8?q?Revert=20"[MOD/#198]=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=BB=A4=EB=B0=8B"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1ea3d847df79ff1d93565dc57e4e60d9c9c1a92f. --- .../repositoryimpl/RegisterRepositoryImpl.kt | 190 ++++++------------ 1 file changed, 63 insertions(+), 127 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index 4070cb42..4cec5ca9 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -116,156 +116,92 @@ class RegisterRepositoryImpl @Inject constructor( private suspend fun testCompressionPerformance(uri: Uri): MultipartBody.Part { val isTestMode = false // true이면 기존 방식, false이면 개선된 방식 - // 1. 원본 이미지 크기 측정 (바이트 단위) - val originalSize: Long = context.contentResolver.openInputStream(uri)?.available()?.toLong() ?: -1L + // 1. 원본 이미지 크기 측정 + val originalSize = context.contentResolver.openInputStream(uri)?.use { + it.available().toLong() + } ?: -1L - // 2. 압축 전 네이티브 힙 메모리 사용량 측정 (바이트 단위) - val nativeHeapBefore: Long = getNativeHeapAllocatedSize() + val beforeMemory = measureMemoryState() - // 3. 이미지 압축 처리 및 실행 시간 측정 + // 3. 이미지 압축 실행 및 시간 측정 lateinit var result: MultipartBody.Part - val elapsedTime = measureTimeMillis { + val compressionTime = measureTimeMillis { result = if (isTestMode) { - // 기존 압축 방식 (예: ContentUriRequestBodyLegacy 사용) ContentUriRequestBodyLegacy(context, uri).toFormData("photos") } else { - // 개선된 압축 방식 (예: ContentUriRequestBody 사용, prepareImage() 호출) ContentUriRequestBody(context, uri).apply { prepareImage() }.toFormData("photos") } } - // 4. 압축 후 네이티브 힙 메모리 사용량 측정 (바이트 단위) - val nativeHeapAfter: Long = getNativeHeapAllocatedSize() + // 4. 압축 후 메모리 상태 + val afterMemory = measureMemoryState() - // 5. 압축에 사용된 네이티브 힙 메모리 (바이트 단위) - val memoryUsedForCompression: Long = nativeHeapAfter - nativeHeapBefore + // 5. 압축된 크기 확인 + val compressedSize = result.body.contentLength() - // 6. 압축 후 이미지 크기 측정 (RequestBody의 contentLength()) - val compressedSize: Long = result.body.contentLength() - - // 7. 로그 출력 (MB 단위로 변환) - val originalSizeMB = originalSize / (1024f * 1024f) - val compressedSizeMB = compressedSize / (1024f * 1024f) - val memoryUsedMB = memoryUsedForCompression / (1024f * 1024f) + // 메모리 사용량 계산 + val memoryDiff = afterMemory - beforeMemory Timber.d( """ - ✨ 이미지 압축 성능 및 메모리 사용량 평가 (${if (isTestMode) "기존" else "개선"} 방식): - 📊 원본 이미지 크기: $originalSizeMB MB - 📉 압축 후 이미지 크기: $compressedSizeMB MB - 📈 압축률: ${"%.2f".format((1 - (compressedSize.toFloat() / originalSize)) * 100)}% - ⏱️ 실행 시간: ${elapsedTime}ms - 💾 압축에 사용된 네이티브 힙 메모리: $memoryUsedMB MB + 📸 이미지 압축 성능 분석 (${if (isTestMode) "기존" else "개선"} 방식): + + 📊 크기 정보: + - 원본: ${originalSize.bytesToMB()} MB + - 압축 후: ${compressedSize.bytesToMB()} MB + - 압축률: ${calculateCompressionRate(originalSize, compressedSize)}% + + 💾 메모리 사용량: + - 압축 전: ${beforeMemory.bytesToMB()} MB + - 압축 후: ${afterMemory.bytesToMB()} MB + - 실제 사용: ${maxOf(0L, memoryDiff).bytesToMB()} MB + + ⚡ 성능: + - 처리 시간: ${compressionTime}ms + - 최대 가용 메모리: ${Runtime.getRuntime().maxMemory().bytesToMB()} MB + + ⚠️ OOM 위험도: ${assessOOMRisk(originalSize, maxOf(0L, memoryDiff))} """.trimIndent() ) - // 원본 이미지 크기와 비교하여, 압축에 사용된 메모리가 과도하다면 경고 로그 출력 (OOM 위험 판단) - if (memoryUsedForCompression > originalSize) { - Timber.w("경고: 압축에 사용된 메모리($memoryUsedMB MB)가 원본 이미지 크기($originalSizeMB MB)보다 큽니다. OOM 위험이 있을 수 있습니다.") - } - return result } - /** - * Debug API를 사용하여 네이티브 힙에 할당된 메모리 크기를 측정합니다. - * 반환 값은 바이트 단위입니다. - */ - private fun getNativeHeapAllocatedSize(): Long { - return Debug.getNativeHeapAllocatedSize() + private fun Long.bytesToMB() = this / (1024.0 * 1024.0) + + private fun calculateCompressionRate(originalSize: Long, compressedSize: Long): String { + return String.format(Locale.US, "%.1f", (1 - compressedSize.toDouble() / originalSize) * 100) } -// private suspend fun testCompressionPerformance(uri: Uri): MultipartBody.Part { -// val isTestMode = false // true이면 기존 방식, false이면 개선된 방식 -// -// // 1. 원본 이미지 크기 측정 -// val originalSize = context.contentResolver.openInputStream(uri)?.use { -// it.available().toLong() -// } ?: -1L -// -// val beforeMemory = measureMemoryState() -// -// // 3. 이미지 압축 실행 및 시간 측정 -// lateinit var result: MultipartBody.Part -// val compressionTime = measureTimeMillis { -// result = if (isTestMode) { -// ContentUriRequestBodyLegacy(context, uri).toFormData("photos") -// } else { -// ContentUriRequestBody(context, uri).apply { -// prepareImage() -// }.toFormData("photos") -// } -// } -// -// // 4. 압축 후 메모리 상태 -// val afterMemory = measureMemoryState() -// -// // 5. 압축된 크기 확인 -// val compressedSize = result.body.contentLength() -// -// // 메모리 사용량 계산 -// val memoryDiff = afterMemory - beforeMemory -// -// Timber.d( -// """ -// 📸 이미지 압축 성능 분석 (${if (isTestMode) "기존" else "개선"} 방식): -// -// 📊 크기 정보: -// - 원본: ${originalSize.bytesToMB()} MB -// - 압축 후: ${compressedSize.bytesToMB()} MB -// - 압축률: ${calculateCompressionRate(originalSize, compressedSize)}% -// -// 💾 메모리 사용량: -// - 압축 전: ${beforeMemory.bytesToMB()} MB -// - 압축 후: ${afterMemory.bytesToMB()} MB -// - 실제 사용: ${maxOf(0L, memoryDiff).bytesToMB()} MB -// -// ⚡ 성능: -// - 처리 시간: ${compressionTime}ms -// - 최대 가용 메모리: ${Runtime.getRuntime().maxMemory().bytesToMB()} MB -// -// ⚠️ OOM 위험도: ${assessOOMRisk(originalSize, maxOf(0L, memoryDiff))} -// """.trimIndent() -// ) -// -// return result -// } -// -// private fun Long.bytesToMB() = this / (1024.0 * 1024.0) -// -// private fun calculateCompressionRate(originalSize: Long, compressedSize: Long): String { -// return String.format(Locale.US, "%.1f", (1 - compressedSize.toDouble() / originalSize) * 100) -// } -// -// private fun assessOOMRisk(originalSize: Long, usedMemory: Long): String { -// val ratio = usedMemory.toDouble() / originalSize.toDouble() -// return when { -// ratio > 3.0 -> "높음 (메모리 사용량이 원본 대비 3배 초과)" -// ratio > 2.0 -> "중간 (메모리 사용량이 원본 대비 2-3배)" -// else -> "낮음 (메모리 사용량이 원본 대비 2배 미만)" -// } -// } -// -// private fun measureMemoryState(): Long { -// var attempt = 0 -// var memoryState: Long -// -// // 최대 3번 시도하며 안정적인 메모리 측정값을 얻음 -// do { -// System.gc() -// Thread.sleep(200) // GC 완료를 위해 대기 시간 증가 -// -// val javaHeap = Runtime.getRuntime().run { -// totalMemory() - freeMemory() -// } -// val nativeHeap = Debug.getNativeHeapAllocatedSize() -// memoryState = javaHeap + nativeHeap -// -// attempt++ -// } while (attempt < 3 && memoryState < 0) -// -// return memoryState -// } + private fun assessOOMRisk(originalSize: Long, usedMemory: Long): String { + val ratio = usedMemory.toDouble() / originalSize.toDouble() + return when { + ratio > 3.0 -> "높음 (메모리 사용량이 원본 대비 3배 초과)" + ratio > 2.0 -> "중간 (메모리 사용량이 원본 대비 2-3배)" + else -> "낮음 (메모리 사용량이 원본 대비 2배 미만)" + } + } + + private fun measureMemoryState(): Long { + var attempt = 0 + var memoryState: Long + + // 최대 3번 시도하며 안정적인 메모리 측정값을 얻음 + do { + System.gc() + Thread.sleep(200) // GC 완료를 위해 대기 시간 증가 + + val javaHeap = Runtime.getRuntime().run { + totalMemory() - freeMemory() + } + val nativeHeap = Debug.getNativeHeapAllocatedSize() + memoryState = javaHeap + nativeHeap + + attempt++ + } while (attempt < 3 && memoryState < 0) + + return memoryState + } } From 5f42a874a1ebeb9387a13c02bd46eb2f69213e3f Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 01:28:53 +0900 Subject: [PATCH 13/30] =?UTF-8?q?[MOD/#198]=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryimpl/RegisterRepositoryImpl.kt | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index 4cec5ca9..72f6489a 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -89,23 +89,13 @@ class RegisterRepositoryImpl @Inject constructor( * * 측정 항목: * - 이미지 크기: - * - 원본 크기 (ContentResolver.available()) - * - 압축 후 크기 (RequestBody.contentLength()) * * - 메모리 사용량: - * - Java Heap: Runtime.totalMemory() - freeMemory() - * - Native Heap: Debug.getNativeHeapAllocatedSize() - * - GC 실행 후 측정하여 정확도 향상 + * - GC 실행 후 측정 * * - 성능 지표: - * - 압축 소요 시간 (measureTimeMillis) - * - 압축률 (원본 대비 압축 후 크기) * * - OOM 위험도 평가: - * - 메모리 사용량/원본 크기 비율 기반 - * - 낮음: 2배 미만 - * - 중간: 2-3배 - * - 높음: 3배 초과 * * 결과는 Timber.d()를 통해 상세 로그로 출력됩니다. * @@ -187,11 +177,9 @@ class RegisterRepositoryImpl @Inject constructor( private fun measureMemoryState(): Long { var attempt = 0 var memoryState: Long - - // 최대 3번 시도하며 안정적인 메모리 측정값을 얻음 do { System.gc() - Thread.sleep(200) // GC 완료를 위해 대기 시간 증가 + Thread.sleep(200) val javaHeap = Runtime.getRuntime().run { totalMemory() - freeMemory() From 59e3457b430cd6984e779019938cd36be4753d96 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 14:12:35 +0900 Subject: [PATCH 14/30] =?UTF-8?q?[MOD/#198]=20=EB=B9=84=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EC=A0=81=EC=9A=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=B2=98=EB=A6=AC=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryimpl/RegisterRepositoryImpl.kt | 143 +++--------------- 1 file changed, 20 insertions(+), 123 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index 72f6489a..5e8fc91e 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -2,9 +2,7 @@ package com.spoony.spoony.data.repositoryimpl import android.content.Context import android.net.Uri -import android.os.Debug import com.spoony.spoony.core.network.ContentUriRequestBody -import com.spoony.spoony.core.network.ContentUriRequestBodyLegacy import com.spoony.spoony.data.datasource.CategoryDataSource import com.spoony.spoony.data.datasource.PlaceDataSource import com.spoony.spoony.data.dto.request.RegisterPostRequestDto @@ -14,14 +12,14 @@ import com.spoony.spoony.domain.entity.CategoryEntity import com.spoony.spoony.domain.entity.PlaceEntity import com.spoony.spoony.domain.repository.RegisterRepository import dagger.hilt.android.qualifiers.ApplicationContext -import java.util.Locale -import javax.inject.Inject -import kotlin.system.measureTimeMillis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType -import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody -import timber.log.Timber +import javax.inject.Inject class RegisterRepositoryImpl @Inject constructor( private val placeDataSource: PlaceDataSource, @@ -71,125 +69,24 @@ class RegisterRepositoryImpl @Inject constructor( menuList = menuList ) - val jsonString = Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto) - val requestBody = jsonString.toRequestBody("application/json".toMediaType()) - - val photoParts = photos.map { uri -> - testCompressionPerformance(uri) - } - - postService.registerPost( - data = requestBody, - photos = photoParts - ).data - } - - /** - * 이미지 압축 성능 및 메모리 사용량 평가 함수 - * - * 측정 항목: - * - 이미지 크기: - * - * - 메모리 사용량: - * - GC 실행 후 측정 - * - * - 성능 지표: - * - * - OOM 위험도 평가: - * - * 결과는 Timber.d()를 통해 상세 로그로 출력됩니다. - * - * @param uri 압축할 이미지의 Uri - * @return 압축된 이미지가 포함된 MultipartBody.Part - */ - - private suspend fun testCompressionPerformance(uri: Uri): MultipartBody.Part { - val isTestMode = false // true이면 기존 방식, false이면 개선된 방식 - - // 1. 원본 이미지 크기 측정 - val originalSize = context.contentResolver.openInputStream(uri)?.use { - it.available().toLong() - } ?: -1L - - val beforeMemory = measureMemoryState() - - // 3. 이미지 압축 실행 및 시간 측정 - lateinit var result: MultipartBody.Part - val compressionTime = measureTimeMillis { - result = if (isTestMode) { - ContentUriRequestBodyLegacy(context, uri).toFormData("photos") - } else { - ContentUriRequestBody(context, uri).apply { - prepareImage() - }.toFormData("photos") + coroutineScope { + val deferredRequestBody = async { + Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto) + .toRequestBody("application/json".toMediaType()) } - } - - // 4. 압축 후 메모리 상태 - val afterMemory = measureMemoryState() - - // 5. 압축된 크기 확인 - val compressedSize = result.body.contentLength() - // 메모리 사용량 계산 - val memoryDiff = afterMemory - beforeMemory - - Timber.d( - """ - 📸 이미지 압축 성능 분석 (${if (isTestMode) "기존" else "개선"} 방식): - - 📊 크기 정보: - - 원본: ${originalSize.bytesToMB()} MB - - 압축 후: ${compressedSize.bytesToMB()} MB - - 압축률: ${calculateCompressionRate(originalSize, compressedSize)}% - - 💾 메모리 사용량: - - 압축 전: ${beforeMemory.bytesToMB()} MB - - 압축 후: ${afterMemory.bytesToMB()} MB - - 실제 사용: ${maxOf(0L, memoryDiff).bytesToMB()} MB - - ⚡ 성능: - - 처리 시간: ${compressionTime}ms - - 최대 가용 메모리: ${Runtime.getRuntime().maxMemory().bytesToMB()} MB - - ⚠️ OOM 위험도: ${assessOOMRisk(originalSize, maxOf(0L, memoryDiff))} - """.trimIndent() - ) - - return result - } - - private fun Long.bytesToMB() = this / (1024.0 * 1024.0) - - private fun calculateCompressionRate(originalSize: Long, compressedSize: Long): String { - return String.format(Locale.US, "%.1f", (1 - compressedSize.toDouble() / originalSize) * 100) - } - - private fun assessOOMRisk(originalSize: Long, usedMemory: Long): String { - val ratio = usedMemory.toDouble() / originalSize.toDouble() - return when { - ratio > 3.0 -> "높음 (메모리 사용량이 원본 대비 3배 초과)" - ratio > 2.0 -> "중간 (메모리 사용량이 원본 대비 2-3배)" - else -> "낮음 (메모리 사용량이 원본 대비 2배 미만)" - } - } - - private fun measureMemoryState(): Long { - var attempt = 0 - var memoryState: Long - do { - System.gc() - Thread.sleep(200) - - val javaHeap = Runtime.getRuntime().run { - totalMemory() - freeMemory() + val deferredPhotoParts = photos.map { uri -> + async(Dispatchers.IO) { + ContentUriRequestBody(context, uri) + .apply { prepareImage() } + .toFormData("photos") + } } - val nativeHeap = Debug.getNativeHeapAllocatedSize() - memoryState = javaHeap + nativeHeap - - attempt++ - } while (attempt < 3 && memoryState < 0) - return memoryState + postService.registerPost( + data = deferredRequestBody.await(), + photos = deferredPhotoParts.awaitAll() + ).data + } } } From 063f77dcc9b840a1560a4a03a9a15930fafe3522 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 14:37:04 +0900 Subject: [PATCH 15/30] =?UTF-8?q?[MOD/#198]=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=ED=96=A5=EC=83=81,=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/network/ContentUriRequestBody.kt | 460 +++++++----------- 1 file changed, 169 insertions(+), 291 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index 71c7a34a..e1aac75b 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -11,8 +11,7 @@ import android.os.Build import android.provider.MediaStore import android.util.Size import androidx.annotation.RequiresApi -import java.io.ByteArrayOutputStream -import javax.inject.Inject +import com.spoony.spoony.BuildConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType @@ -21,23 +20,44 @@ import okhttp3.MultipartBody import okhttp3.RequestBody import okio.BufferedSink import timber.log.Timber +import java.io.ByteArrayOutputStream +import javax.inject.Inject class ContentUriRequestBody @Inject constructor( context: Context, - private val uri: Uri? + 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 private constructor( + private data class ImageMetadata( val fileName: String, - val size: Long = 0L, + val size: Long, val mimeType: String? ) { companion object { - fun create(fileName: String, size: Long, mimeType: String?) = - ImageMetadata(fileName, size, mimeType) + 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 + ) } } @@ -47,121 +67,154 @@ class ContentUriRequestBody @Inject constructor( } } - private fun extractMetadata(uri: Uri): ImageMetadata { - var fileName = "" - var size = 0L - - contentResolver.query( - uri, - arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), - null, - null, - null - )?.use { cursor -> - if (cursor.moveToFirst()) { - size = cursor.getLong( - cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE) - ) - fileName = cursor.getString( - cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) - ) + 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 = runCatching { + withContext(Dispatchers.IO) { + uri?.let { safeUri -> + compressImage(safeUri).onSuccess { bytes -> + compressedImage = bytes + } } } - - return ImageMetadata.create( - fileName = fileName, - size = size, - mimeType = contentResolver.getType(uri) - ) } - suspend fun prepareImage() = withContext(Dispatchers.IO) { - uri?.let { safeUri -> - runCatching { - compressedImage = compressImage(safeUri) - }.onFailure { error -> - Timber.e(error, "이미지 압축에 실패했습니다.") - throw error + private suspend fun compressImage(uri: Uri): Result = + withContext(Dispatchers.IO) { + loadBitmap(uri).map { bitmap -> + compressBitmap(bitmap).also { + bitmap.recycle() + } } } - } - private suspend fun compressImage(uri: Uri): ByteArray = withContext(Dispatchers.IO) { - val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - loadBitmapWithImageDecoder(uri) - } else { - loadBitmapLegacy(uri) + @RequiresApi(Build.VERSION_CODES.Q) + private suspend fun loadBitmapWithImageDecoder(uri: Uri): Result = + 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) + } + } + } } - compressBitmap(bitmap).also { - bitmap.recycle() + private suspend fun loadBitmap(uri: Uri): Result = + withContext(Dispatchers.IO) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + loadBitmapWithImageDecoder(uri) + } else { + loadBitmapLegacy(uri) + } } - } - - @RequiresApi(Build.VERSION_CODES.Q) - private suspend fun loadBitmapWithImageDecoder(uri: Uri): Bitmap = withContext(Dispatchers.IO) { - val source = ImageDecoder.createSource(contentResolver, uri) - ImageDecoder.decodeBitmap(source) { decoder, info, _ -> - decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE - decoder.isMutableRequired = true - val size = calculateTargetSize(info.size.width, info.size.height) - decoder.setTargetSize(size.width, size.height) - } - } + private suspend fun loadBitmapLegacy(uri: Uri): Result = + withContext(Dispatchers.IO) { + runCatching { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } - private suspend fun loadBitmapLegacy(uri: Uri): Bitmap = withContext(Dispatchers.IO) { - val options = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } + contentResolver.openInputStream(uri)?.use { input -> + BitmapFactory.decodeStream(input, null, options) + } - requireNotNull( - contentResolver.openInputStream(uri)?.use { input -> - BitmapFactory.decodeStream(input, null, options) options.apply { inJustDecodeBounds = false - inSampleSize = calculateInSampleSize(this, MAX_WIDTH, MAX_HEIGHT) + inSampleSize = calculateInSampleSize(outWidth, outHeight) inPreferredConfig = Bitmap.Config.ARGB_8888 } - contentResolver.openInputStream(uri)?.use { secondInput -> - BitmapFactory.decodeStream(secondInput, null, options) + 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) { + ByteArrayOutputStream(bitmap.byteCount / 2).use { buffer -> + var lowerQuality = config.minQuality + var upperQuality = config.initialQuality + var bestQuality = lowerQuality + var bestByteArray = ByteArray(0) + + while (lowerQuality <= upperQuality) { + val midQuality = (lowerQuality + upperQuality) / 2 + buffer.reset() + + bitmap.compress(config.format, midQuality, buffer) + buffer.toByteArray().let { byteArray -> + if (byteArray.size <= config.maxFileSize) { + bestQuality = midQuality + bestByteArray = byteArray + lowerQuality = midQuality + 1 + } else { + upperQuality = midQuality - 1 + } + } } + + if (BuildConfig.DEBUG) { + Timber.d("Compression completed - Quality: $bestQuality, Size: ${bestByteArray.size} bytes") + } + bestByteArray } - ) { "비트맵 디코딩 실패" } - }.let { bitmap -> - val orientation = getOrientation(uri) - if (orientation != ORIENTATION_NORMAL) { - rotateBitmap(bitmap, orientation) - } else { - bitmap } - } - private fun getOrientation(uri: Uri): Int = + private fun getOrientation(uri: Uri): Int { contentResolver.query( uri, arrayOf(MediaStore.Images.Media.ORIENTATION), null, null, null - )?.use { - if (it.moveToFirst()) { - it.getInt(it.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION)) - } else { - ORIENTATION_NORMAL + )?.use { cursor -> + if (cursor.moveToFirst()) { + return cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION)) } - } ?: getExifOrientation(uri) + } + return getExifOrientation(uri) + } private fun getExifOrientation(uri: Uri): Int = contentResolver.openInputStream(uri)?.use { input -> - val exif = ExifInterface(input) - when ( - exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL - ) - ) { + 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 @@ -170,68 +223,35 @@ class ContentUriRequestBody @Inject constructor( } ?: ORIENTATION_NORMAL private fun rotateBitmap(bitmap: Bitmap, angle: Int): Bitmap = - Bitmap.createBitmap( - bitmap, - 0, - 0, - bitmap.width, - bitmap.height, - Matrix().apply { postRotate(angle.toFloat()) }, - true - ).also { - if (it != bitmap) { - bitmap.recycle() - } - } - - private suspend fun compressBitmap(bitmap: Bitmap): ByteArray = withContext(Dispatchers.IO) { - val maxFileSize = MAX_FILE_SIZE_BYTES - var lowerQuality = MIN_QUALITY // 최소 품질 (예: 20) - var upperQuality = INITIAL_QUALITY // 초기 품질 (예: 100) - var bestQuality = lowerQuality // 조건을 만족하는 최고 품질 값 - var bestByteArray = ByteArray(0) - - // 이진 탐색을 통해 파일 크기가 maxFileSize 이하가 되는 최대 품질을 찾음 - while (lowerQuality <= upperQuality) { - val midQuality = (lowerQuality + upperQuality) / 2 - - // 임시 ByteArrayOutputStream에 bitmap을 midQuality로 압축 - val byteArray = ByteArrayOutputStream().use { outputStream -> - bitmap.compress(Bitmap.CompressFormat.JPEG, midQuality, outputStream) - outputStream.toByteArray() + runCatching { + Matrix().apply { + postRotate(angle.toFloat()) + }.let { matrix -> + Bitmap.createBitmap( + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + matrix, + true + ) } - val size = byteArray.size - - if (size <= maxFileSize) { - // 압축 결과가 1MB 이하이면, 더 높은 품질을 시도하기 위해 하한선을 올림 - bestQuality = midQuality - bestByteArray = byteArray - lowerQuality = midQuality + 1 - } else { - // 파일 크기가 너무 크면, 상한선을 낮춤 - upperQuality = midQuality - 1 + }.onSuccess { rotatedBitmap -> + if (rotatedBitmap != bitmap) { + bitmap.recycle() } - } - - Timber.d("선택된 품질: $bestQuality, 압축된 이미지 크기: ${bestByteArray.size} 바이트") - bestByteArray - } + }.getOrDefault(bitmap) - private fun calculateInSampleSize( - options: BitmapFactory.Options, - reqWidth: Int, - reqHeight: Int - ): Int { - val (height: Int, width: Int) = options.run { outHeight to outWidth } + private fun calculateInSampleSize(width: Int, height: Int): Int { var inSampleSize = 1 - if (height > reqHeight || width > reqWidth) { - val halfHeight: Int = height / 2 - val halfWidth: Int = width / 2 + if (height > config.maxHeight || width > config.maxWidth) { + val halfHeight = height / 2 + val halfWidth = width / 2 - while (halfHeight / inSampleSize >= reqHeight && - halfWidth / inSampleSize >= reqWidth - ) { + while (halfHeight / inSampleSize >= config.maxHeight && + halfWidth / inSampleSize >= config.maxWidth) { inSampleSize *= 2 } } @@ -242,16 +262,14 @@ class ContentUriRequestBody @Inject constructor( private fun calculateTargetSize(width: Int, height: Int): Size { val ratio = width.toFloat() / height.toFloat() return if (width > height) { - Size(MAX_WIDTH, (MAX_WIDTH / ratio).toInt()) + Size(config.maxWidth, (config.maxWidth / ratio).toInt()) } else { - Size((MAX_HEIGHT * ratio).toInt(), MAX_HEIGHT) + 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) } @@ -262,151 +280,11 @@ class ContentUriRequestBody @Inject constructor( this ) - private companion object { - // 이미지 크기 관련 - private const val MAX_WIDTH = 1024 - private const val MAX_HEIGHT = 1024 - private const val MAX_FILE_SIZE_BYTES = 1024 * 1024 // 1MB - - // 압축 품질 관련 - private const val INITIAL_QUALITY = 100 - private const val MIN_QUALITY = 20 - - // 이미지 회전 관련 + companion object { private const val ORIENTATION_NORMAL = 0 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" } } - -class ContentUriRequestBodyLegacy( - context: Context, - private val uri: Uri? -) : RequestBody() { - private val contentResolver = context.contentResolver - - private var fileName = "" - private var size = -1L - private var compressedImage: ByteArray? = null - - init { - if (uri != null) { - contentResolver.query( - uri, - arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), - null, - null, - null - )?.use { cursor -> - if (cursor.moveToFirst()) { - size = - cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)) - fileName = - cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) - } - } - - compressBitmap() - } - } - - private fun compressBitmap() { - if (uri != null) { - var originalBitmap: Bitmap - val exif: ExifInterface - - contentResolver.openInputStream(uri).use { inputStream -> - if (inputStream == null) return - val option = BitmapFactory.Options().apply { - inSampleSize = calculateInSampleSize(this, MAX_WIDTH, MAX_HEIGHT) - } - originalBitmap = BitmapFactory.decodeStream(inputStream, null, option) ?: return - exif = ExifInterface(inputStream) - } - - var orientation = exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL - ) - when (orientation) { - ExifInterface.ORIENTATION_ROTATE_90 -> orientation = 90 - ExifInterface.ORIENTATION_ROTATE_180 -> orientation = 180 - ExifInterface.ORIENTATION_ROTATE_270 -> orientation = 270 - } - - if (orientation >= 90) { - val matrix = Matrix().apply { - setRotate(orientation.toFloat()) - } - - val rotatedBitmap = Bitmap.createBitmap( - originalBitmap, - 0, - 0, - originalBitmap.width, - originalBitmap.height, - matrix, - true - ) - originalBitmap.recycle() - originalBitmap = rotatedBitmap - } - - val outputStream = ByteArrayOutputStream() - val imageSizeMb = size / (MAX_WIDTH * MAX_HEIGHT.toDouble()) - outputStream.use { - val compressRate = ((IMAGE_SIZE_MB / imageSizeMb) * 100).toInt() - originalBitmap.compress( - Bitmap.CompressFormat.JPEG, - if (imageSizeMb >= IMAGE_SIZE_MB) compressRate else 100, - it - ) - } - compressedImage = outputStream.toByteArray() - size = compressedImage?.size?.toLong() ?: -1L - } - } - - private fun calculateInSampleSize( - options: BitmapFactory.Options, - reqWidth: Int, - reqHeight: Int - ): Int { - 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 - - while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { - inSampleSize *= 2 - } - } - - return inSampleSize - } - - private fun getFileName() = fileName - - override fun contentLength(): Long = size - - override fun contentType(): MediaType? = - uri?.let { contentResolver.getType(it)?.toMediaTypeOrNull() } - - override fun writeTo(sink: BufferedSink) { - compressedImage?.let(sink::write) - } - - fun toFormData(name: String) = MultipartBody.Part.createFormData(name, getFileName(), this) - - companion object { - const val IMAGE_SIZE_MB = 1 - const val MAX_WIDTH = 1024 - const val MAX_HEIGHT = 1024 - } -} From 121b411e9ca21901ece534b6d9c3283e0726797c Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 14:49:49 +0900 Subject: [PATCH 16/30] =?UTF-8?q?[MOD/#198]=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EC=A1=B0=EC=A0=88=20=EB=B0=8F=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=20=EB=B0=B0=EC=97=B4=20=EB=B3=B5=EC=82=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/network/ContentUriRequestBody.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index e1aac75b..5149feb2 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -22,6 +22,7 @@ import okio.BufferedSink import timber.log.Timber import java.io.ByteArrayOutputStream import javax.inject.Inject +import kotlin.math.min class ContentUriRequestBody @Inject constructor( context: Context, @@ -163,32 +164,32 @@ class ContentUriRequestBody @Inject constructor( private suspend fun compressBitmap(bitmap: Bitmap): ByteArray = withContext(Dispatchers.IO) { - ByteArrayOutputStream(bitmap.byteCount / 2).use { buffer -> + val estimatedSize = min(bitmap.byteCount / 4, config.maxFileSize) + ByteArrayOutputStream(estimatedSize).use { buffer -> var lowerQuality = config.minQuality var upperQuality = config.initialQuality var bestQuality = lowerQuality - var bestByteArray = ByteArray(0) + var finalByteArray: ByteArray? = null while (lowerQuality <= upperQuality) { val midQuality = (lowerQuality + upperQuality) / 2 buffer.reset() bitmap.compress(config.format, midQuality, buffer) - buffer.toByteArray().let { byteArray -> - if (byteArray.size <= config.maxFileSize) { - bestQuality = midQuality - bestByteArray = byteArray - lowerQuality = midQuality + 1 - } else { - upperQuality = midQuality - 1 - } + + if (buffer.size() <= config.maxFileSize) { + bestQuality = midQuality + lowerQuality = midQuality + 1 + finalByteArray = buffer.toByteArray() + } else { + upperQuality = midQuality - 1 } } if (BuildConfig.DEBUG) { - Timber.d("Compression completed - Quality: $bestQuality, Size: ${bestByteArray.size} bytes") + Timber.d("Compression completed - Quality: $bestQuality, Size: ${finalByteArray?.size ?: 0} bytes") } - bestByteArray + finalByteArray ?: ByteArray(0) } } From 4e908e957ec12848d244958c23adb7ec0a9b031c Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 16:14:04 +0900 Subject: [PATCH 17/30] =?UTF-8?q?[MOD/#198]=20calculateInSampleSize=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/network/ContentUriRequestBody.kt | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index 5149feb2..c5e2f0ab 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -22,6 +22,7 @@ import okio.BufferedSink import timber.log.Timber import java.io.ByteArrayOutputStream import javax.inject.Inject +import kotlin.math.max import kotlin.math.min class ContentUriRequestBody @Inject constructor( @@ -245,21 +246,11 @@ class ContentUriRequestBody @Inject constructor( }.getOrDefault(bitmap) private fun calculateInSampleSize(width: Int, height: Int): Int { - var inSampleSize = 1 - - if (height > config.maxHeight || width > config.maxWidth) { - val halfHeight = height / 2 - val halfWidth = width / 2 - - while (halfHeight / inSampleSize >= config.maxHeight && - halfWidth / inSampleSize >= config.maxWidth) { - inSampleSize *= 2 - } - } - - return inSampleSize + 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) { From c2b3c98284dd94f6be6b017fc58fd22a695d73de Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 16:22:17 +0900 Subject: [PATCH 18/30] =?UTF-8?q?[MOD/#198]=20compressBitmap=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=ED=95=A0=EB=8B=B9=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/spoony/spoony/core/network/ContentUriRequestBody.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index c5e2f0ab..098304ae 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -170,7 +170,6 @@ class ContentUriRequestBody @Inject constructor( var lowerQuality = config.minQuality var upperQuality = config.initialQuality var bestQuality = lowerQuality - var finalByteArray: ByteArray? = null while (lowerQuality <= upperQuality) { val midQuality = (lowerQuality + upperQuality) / 2 @@ -181,16 +180,15 @@ class ContentUriRequestBody @Inject constructor( if (buffer.size() <= config.maxFileSize) { bestQuality = midQuality lowerQuality = midQuality + 1 - finalByteArray = buffer.toByteArray() } else { upperQuality = midQuality - 1 } } if (BuildConfig.DEBUG) { - Timber.d("Compression completed - Quality: $bestQuality, Size: ${finalByteArray?.size ?: 0} bytes") + Timber.d("Compression completed - Quality: $bestQuality, Size: ${buffer.size()} bytes") } - finalByteArray ?: ByteArray(0) + return@use buffer.toByteArray() } } From 60f54cf95cae495da9022a3cdbdb36ed420832e1 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 18:31:49 +0900 Subject: [PATCH 19/30] [MOD/#198] lint format --- .../core/network/ContentUriRequestBody.kt | 17 ++++++++++------- .../repositoryimpl/RegisterRepositoryImpl.kt | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index 098304ae..ee3a3ff2 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -12,6 +12,10 @@ 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 @@ -20,10 +24,6 @@ import okhttp3.MultipartBody import okhttp3.RequestBody import okio.BufferedSink import timber.log.Timber -import java.io.ByteArrayOutputStream -import javax.inject.Inject -import kotlin.math.max -import kotlin.math.min class ContentUriRequestBody @Inject constructor( context: Context, @@ -87,7 +87,9 @@ class ContentUriRequestBody @Inject constructor( size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)), mimeType = contentResolver.getType(uri) ) - } else ImageMetadata.EMPTY + } else { + ImageMetadata.EMPTY + } } ?: ImageMetadata.EMPTY }.getOrDefault(ImageMetadata.EMPTY) @@ -157,7 +159,9 @@ class ContentUriRequestBody @Inject constructor( getOrientation(uri).let { orientation -> if (orientation != ORIENTATION_NORMAL) { rotateBitmap(bitmap, orientation) - } else bitmap + } else { + bitmap + } } } ?: error("비트맵 디코딩 실패") } @@ -248,7 +252,6 @@ class ContentUriRequestBody @Inject constructor( return Integer.highestOneBit(ratio) } - private fun calculateTargetSize(width: Int, height: Int): Size { val ratio = width.toFloat() / height.toFloat() return if (width > height) { diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index 5e8fc91e..acc663c7 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -12,6 +12,7 @@ import com.spoony.spoony.domain.entity.CategoryEntity import com.spoony.spoony.domain.entity.PlaceEntity import com.spoony.spoony.domain.repository.RegisterRepository import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -19,7 +20,6 @@ import kotlinx.coroutines.coroutineScope import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody -import javax.inject.Inject class RegisterRepositoryImpl @Inject constructor( private val placeDataSource: PlaceDataSource, From 77cd50d8ee5915ffa445cdac89f000a6255ed129 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 19:15:12 +0900 Subject: [PATCH 20/30] [MOD/#198] lint format --- app/build.gradle.kts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fa248e93..ad336219 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { // Androidx implementation(libs.bundles.androidx) implementation(platform(libs.androidx.compose.bom)) + implementation(libs.kotlinx.immutable) // Network implementation(platform(libs.okhttp.bom)) @@ -88,8 +89,6 @@ dependencies { implementation(libs.lottie) implementation(libs.advanced.bottom.sheet) - implementation(libs.kotlinx.immutable) - // Naver Map implementation(libs.bundles.naverMap) From 54fa0ea61d873acb3ca08b42d5af61950595b103 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 19:38:03 +0900 Subject: [PATCH 21/30] =?UTF-8?q?[MOD/#198]=20lint=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/spoony/spoony/core/database/di/DatabaseModule.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/spoony/spoony/core/database/di/DatabaseModule.kt b/app/src/main/java/com/spoony/spoony/core/database/di/DatabaseModule.kt index 4dc39fe9..f3c8d115 100644 --- a/app/src/main/java/com/spoony/spoony/core/database/di/DatabaseModule.kt +++ b/app/src/main/java/com/spoony/spoony/core/database/di/DatabaseModule.kt @@ -12,7 +12,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -object DBModule { +object DatabaseModule { @Singleton @Provides fun providesDataBase( @@ -26,3 +26,4 @@ object DBModule { searchDatabase: SearchDatabase ) = searchDatabase.SearchDao() } + From e6a0538bb1727ab32ec118d8f1ccb67fb1d32aea Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 11 Feb 2025 19:40:50 +0900 Subject: [PATCH 22/30] =?UTF-8?q?[MOD/#198]=20lint=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/spoony/spoony/core/database/di/DatabaseModule.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/spoony/spoony/core/database/di/DatabaseModule.kt b/app/src/main/java/com/spoony/spoony/core/database/di/DatabaseModule.kt index f3c8d115..2ec46472 100644 --- a/app/src/main/java/com/spoony/spoony/core/database/di/DatabaseModule.kt +++ b/app/src/main/java/com/spoony/spoony/core/database/di/DatabaseModule.kt @@ -26,4 +26,3 @@ object DatabaseModule { searchDatabase: SearchDatabase ) = searchDatabase.SearchDao() } - From 96f1e6566402dde0d200f754b058999b95343198 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Sun, 16 Feb 2025 23:35:55 +0900 Subject: [PATCH 23/30] =?UTF-8?q?[MOD/#198]=20API=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EB=B3=84=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/network/ContentUriRequestBody.kt | 126 ++++++------------ 1 file changed, 42 insertions(+), 84 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index ee3a3ff2..078cf3c1 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -2,19 +2,14 @@ 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 @@ -30,6 +25,7 @@ class ContentUriRequestBody @Inject constructor( 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 @@ -69,30 +65,6 @@ class ContentUriRequestBody @Inject constructor( } } - 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 = runCatching { withContext(Dispatchers.IO) { uri?.let { safeUri -> @@ -103,6 +75,12 @@ class ContentUriRequestBody @Inject constructor( } } + fun toFormData(name: String): MultipartBody.Part = MultipartBody.Part.createFormData( + name, + metadata?.fileName ?: DEFAULT_FILE_NAME, + this + ) + private suspend fun compressImage(uri: Uri): Result = withContext(Dispatchers.IO) { loadBitmap(uri).map { bitmap -> @@ -112,8 +90,7 @@ class ContentUriRequestBody @Inject constructor( } } - @RequiresApi(Build.VERSION_CODES.Q) - private suspend fun loadBitmapWithImageDecoder(uri: Uri): Result = + private suspend fun loadBitmap(uri: Uri): Result = withContext(Dispatchers.IO) { runCatching { val source = ImageDecoder.createSource(contentResolver, uri) @@ -124,46 +101,14 @@ class ContentUriRequestBody @Inject constructor( decoder.setTargetSize(size.width, size.height) } } - } - } - - private suspend fun loadBitmap(uri: Uri): Result = - withContext(Dispatchers.IO) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - loadBitmapWithImageDecoder(uri) - } else { - loadBitmapLegacy(uri) - } - } - - private suspend fun loadBitmapLegacy(uri: Uri): Result = - 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 - } + }.map { bitmap -> + getOrientation(uri).let { orientation -> + if (orientation != ORIENTATION_NORMAL) { + rotateBitmap(bitmap, orientation) + } else { + bitmap } - } ?: error("비트맵 디코딩 실패") + } } } @@ -189,13 +134,35 @@ class ContentUriRequestBody @Inject constructor( } } - if (BuildConfig.DEBUG) { - Timber.d("Compression completed - Quality: $bestQuality, Size: ${buffer.size()} bytes") - } + Timber.d("Compression completed - Quality: $bestQuality, Size: ${buffer.size()} bytes") return@use buffer.toByteArray() } } + 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) + private fun getOrientation(uri: Uri): Int { contentResolver.query( uri, @@ -247,11 +214,6 @@ class ContentUriRequestBody @Inject constructor( } }.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) { @@ -262,17 +224,13 @@ class ContentUriRequestBody @Inject constructor( } 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 private const val ORIENTATION_ROTATE_90 = 90 From 7d240a9f1486eb6592eabad946c2b847e17eb6ea Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Mon, 17 Feb 2025 23:58:38 +0900 Subject: [PATCH 24/30] =?UTF-8?q?[MOD/#198]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A6=AC=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A7=95=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spoony/core/network/ContentUriRequestBody.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index 078cf3c1..afb46850 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -215,14 +215,16 @@ class ContentUriRequestBody @Inject constructor( }.getOrDefault(bitmap) 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) + if (width <= config.maxWidth && height <= config.maxHeight) { + return Size(width, height) } + val scaleFactor = min(config.maxWidth / width.toFloat(), config.maxHeight / height.toFloat()) + val targetWidth = (width * scaleFactor).toInt() + val targetHeight = (height * scaleFactor).toInt() + return Size(targetWidth, targetHeight) } + override fun contentLength(): Long = compressedImage?.size?.toLong() ?: -1L override fun contentType(): MediaType? = metadata?.mimeType?.toMediaTypeOrNull() From f4d67c89418679adcebfc886733d9dbc5ca66393 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 18 Feb 2025 00:09:11 +0900 Subject: [PATCH 25/30] [MOD/#198] Fix lint error --- .../java/com/spoony/spoony/core/network/ContentUriRequestBody.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt index afb46850..55a94109 100644 --- a/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -224,7 +224,6 @@ class ContentUriRequestBody @Inject constructor( return Size(targetWidth, targetHeight) } - override fun contentLength(): Long = compressedImage?.size?.toLong() ?: -1L override fun contentType(): MediaType? = metadata?.mimeType?.toMediaTypeOrNull() From 28798fb4122c21df4c30e237ab990e1894bcfd57 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 18 Feb 2025 00:52:36 +0900 Subject: [PATCH 26/30] =?UTF-8?q?[MOD/#198]=20=EC=BD=94=EB=A3=A8=ED=8B=B4?= =?UTF-8?q?=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=94=94=EC=8A=A4=ED=8C=A8=EC=B2=98=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryimpl/RegisterRepositoryImpl.kt | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index acc663c7..03d93475 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody @@ -55,38 +56,38 @@ class RegisterRepositoryImpl @Inject constructor( categoryId: Int, menuList: List, photos: List - ): Result = runCatching { - val requestDto = RegisterPostRequestDto( - userId = userId, - title = title, - description = description, - placeName = placeName, - placeAddress = placeAddress, - placeRoadAddress = placeRoadAddress, - latitude = latitude, - longitude = longitude, - categoryId = categoryId, - menuList = menuList - ) + ): Result = withContext(Dispatchers.IO) { + runCatching { + val requestDto = RegisterPostRequestDto( + userId = userId, + title = title, + description = description, + placeName = placeName, + placeAddress = placeAddress, + placeRoadAddress = placeRoadAddress, + latitude = latitude, + longitude = longitude, + categoryId = categoryId, + menuList = menuList + ) - coroutineScope { - val deferredRequestBody = async { - Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto) + coroutineScope { + val requestBody = Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto) .toRequestBody("application/json".toMediaType()) - } - val deferredPhotoParts = photos.map { uri -> - async(Dispatchers.IO) { - ContentUriRequestBody(context, uri) - .apply { prepareImage() } - .toFormData("photos") + val photoParts = photos.map { uri -> + async { + ContentUriRequestBody(context, uri) + .apply { prepareImage() } + .toFormData("photos") + } } - } - postService.registerPost( - data = deferredRequestBody.await(), - photos = deferredPhotoParts.awaitAll() - ).data + postService.registerPost( + data = requestBody, + photos = photoParts.awaitAll() + ).data!! + } } } } From fbce7246488493f4bee59e0e2e7204799fd4e186 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Tue, 18 Feb 2025 00:53:33 +0900 Subject: [PATCH 27/30] =?UTF-8?q?[MOD/#198]=20=EB=AC=B8=EC=9E=90=EC=97=B4?= =?UTF-8?q?=20=EC=83=81=EC=88=98=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spoony/data/repositoryimpl/RegisterRepositoryImpl.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index 03d93475..9aa97a79 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -73,13 +73,13 @@ class RegisterRepositoryImpl @Inject constructor( coroutineScope { val requestBody = Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto) - .toRequestBody("application/json".toMediaType()) + .toRequestBody(MEDIA_TYPE_JSON.toMediaType()) val photoParts = photos.map { uri -> async { ContentUriRequestBody(context, uri) .apply { prepareImage() } - .toFormData("photos") + .toFormData(FORM_DATA_NAME_PHOTOS) } } @@ -90,4 +90,9 @@ class RegisterRepositoryImpl @Inject constructor( } } } + + companion object { + private const val MEDIA_TYPE_JSON = "application/json" + private const val FORM_DATA_NAME_PHOTOS = "photos" + } } From 302628a5fbe97b6caecf35a86c3f5e154ff2d4d3 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Wed, 19 Feb 2025 03:04:53 +0900 Subject: [PATCH 28/30] =?UTF-8?q?[MOD/#198]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20Dispatcher=20=EC=A4=91=EC=B2=A9=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B8=ED=95=9C=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryimpl/RegisterRepositoryImpl.kt | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index 9aa97a79..2c520ce5 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -13,14 +13,14 @@ import com.spoony.spoony.domain.entity.PlaceEntity import com.spoony.spoony.domain.repository.RegisterRepository import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody +import timber.log.Timber +import kotlin.system.measureTimeMillis class RegisterRepositoryImpl @Inject constructor( private val placeDataSource: PlaceDataSource, @@ -56,38 +56,38 @@ class RegisterRepositoryImpl @Inject constructor( categoryId: Int, menuList: List, photos: List - ): Result = withContext(Dispatchers.IO) { - runCatching { - val requestDto = RegisterPostRequestDto( - userId = userId, - title = title, - description = description, - placeName = placeName, - placeAddress = placeAddress, - placeRoadAddress = placeRoadAddress, - latitude = latitude, - longitude = longitude, - categoryId = categoryId, - menuList = menuList - ) - - coroutineScope { - val requestBody = Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto) - .toRequestBody(MEDIA_TYPE_JSON.toMediaType()) - - val photoParts = photos.map { uri -> + ): Result = runCatching { + coroutineScope { + val totalTime = measureTimeMillis { + val asyncRequestBody = async { + val requestDto = RegisterPostRequestDto( + userId = userId, + title = title, + description = description, + placeName = placeName, + placeAddress = placeAddress, + placeRoadAddress = placeRoadAddress, + latitude = latitude, + longitude = longitude, + categoryId = categoryId, + menuList = menuList + ) + Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto) + .toRequestBody(MEDIA_TYPE_JSON.toMediaType()) + } + val asyncPhotoParts = photos.map { uri -> async { ContentUriRequestBody(context, uri) .apply { prepareImage() } .toFormData(FORM_DATA_NAME_PHOTOS) } } - postService.registerPost( - data = requestBody, - photos = photoParts.awaitAll() - ).data!! + data = asyncRequestBody.await(), + photos = asyncPhotoParts.awaitAll() + ).data } + Timber.d("전체 업로드 소요 시간: $totalTime ms") } } From 526ad100809b53c5da49626992dab6911eb630cb Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Wed, 19 Feb 2025 03:05:10 +0900 Subject: [PATCH 29/30] =?UTF-8?q?[MOD/#198]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20Dispatcher=20=EC=A4=91=EC=B2=A9=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B8=ED=95=9C=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index 2c520ce5..89560a63 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -13,6 +13,7 @@ import com.spoony.spoony.domain.entity.PlaceEntity import com.spoony.spoony.domain.repository.RegisterRepository import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject +import kotlin.system.measureTimeMillis import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -20,7 +21,6 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import timber.log.Timber -import kotlin.system.measureTimeMillis class RegisterRepositoryImpl @Inject constructor( private val placeDataSource: PlaceDataSource, From bf050f0545e5be37fba48f5b9ac983f516777528 Mon Sep 17 00:00:00 2001 From: MinJae Han Date: Wed, 19 Feb 2025 03:05:10 +0900 Subject: [PATCH 30/30] =?UTF-8?q?[MOD/#198]=20POST=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=B8=A1=EC=A0=95=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=A9=94=EC=84=B8=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt index 2c520ce5..89560a63 100644 --- a/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt +++ b/app/src/main/java/com/spoony/spoony/data/repositoryimpl/RegisterRepositoryImpl.kt @@ -13,6 +13,7 @@ import com.spoony.spoony.domain.entity.PlaceEntity import com.spoony.spoony.domain.repository.RegisterRepository import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject +import kotlin.system.measureTimeMillis import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -20,7 +21,6 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import timber.log.Timber -import kotlin.system.measureTimeMillis class RegisterRepositoryImpl @Inject constructor( private val placeDataSource: PlaceDataSource,