diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21b32cc4..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) 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..55a94109 --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/network/ContentUriRequestBody.kt @@ -0,0 +1,242 @@ +package com.spoony.spoony.core.network + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.graphics.Matrix +import android.media.ExifInterface +import android.net.Uri +import android.provider.MediaStore +import android.util.Size +import java.io.ByteArrayOutputStream +import javax.inject.Inject +import kotlin.math.min +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okio.BufferedSink +import timber.log.Timber + +class ContentUriRequestBody @Inject constructor( + context: Context, + private val uri: Uri?, + private val config: ImageConfig = ImageConfig.DEFAULT +) : RequestBody() { + + private val contentResolver = context.contentResolver + private var compressedImage: ByteArray? = null + private var metadata: ImageMetadata? = null + + private data class ImageMetadata( + val fileName: String, + val size: Long, + val mimeType: String? + ) { + companion object { + val EMPTY = ImageMetadata("", 0L, null) + } + } + + data class ImageConfig( + val maxWidth: Int, + val maxHeight: Int, + val maxFileSize: Int, + val initialQuality: Int, + val minQuality: Int, + val format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG + ) { + companion object { + val DEFAULT = ImageConfig( + maxWidth = 1024, + maxHeight = 1024, + maxFileSize = 1024 * 1024, + initialQuality = 100, + minQuality = 20 + ) + } + } + + init { + uri?.let { + metadata = extractMetadata(it) + } + } + + suspend fun prepareImage(): Result = runCatching { + withContext(Dispatchers.IO) { + uri?.let { safeUri -> + compressImage(safeUri).onSuccess { bytes -> + compressedImage = bytes + } + } + } + } + + 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 -> + compressBitmap(bitmap).also { + bitmap.recycle() + } + } + } + + private suspend fun loadBitmap(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) + } + } + }.map { bitmap -> + getOrientation(uri).let { orientation -> + if (orientation != ORIENTATION_NORMAL) { + rotateBitmap(bitmap, orientation) + } else { + bitmap + } + } + } + } + + private suspend fun compressBitmap(bitmap: Bitmap): ByteArray = + withContext(Dispatchers.IO) { + val estimatedSize = min(bitmap.byteCount / 4, config.maxFileSize) + ByteArrayOutputStream(estimatedSize).use { buffer -> + var lowerQuality = config.minQuality + var upperQuality = config.initialQuality + var bestQuality = lowerQuality + + while (lowerQuality <= upperQuality) { + val midQuality = (lowerQuality + upperQuality) / 2 + buffer.reset() + + bitmap.compress(config.format, midQuality, buffer) + + if (buffer.size() <= config.maxFileSize) { + bestQuality = midQuality + lowerQuality = midQuality + 1 + } else { + upperQuality = midQuality - 1 + } + } + + 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, + arrayOf(MediaStore.Images.Media.ORIENTATION), + null, + null, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + return cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION)) + } + } + return getExifOrientation(uri) + } + + private fun getExifOrientation(uri: Uri): Int = + contentResolver.openInputStream(uri)?.use { input -> + ExifInterface(input).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + }?.let { orientation -> + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> ORIENTATION_ROTATE_90 + ExifInterface.ORIENTATION_ROTATE_180 -> ORIENTATION_ROTATE_180 + ExifInterface.ORIENTATION_ROTATE_270 -> ORIENTATION_ROTATE_270 + else -> ORIENTATION_NORMAL + } + } ?: ORIENTATION_NORMAL + + private fun rotateBitmap(bitmap: Bitmap, angle: Int): Bitmap = + runCatching { + Matrix().apply { + postRotate(angle.toFloat()) + }.let { matrix -> + Bitmap.createBitmap( + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + matrix, + true + ) + } + }.onSuccess { rotatedBitmap -> + if (rotatedBitmap != bitmap) { + bitmap.recycle() + } + }.getOrDefault(bitmap) + + private fun calculateTargetSize(width: Int, height: Int): Size { + 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() + + override fun writeTo(sink: BufferedSink) { + compressedImage?.let(sink::write) + } + + 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" + } +} 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 - } -} 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..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 @@ -2,6 +2,7 @@ package com.spoony.spoony.data.repositoryimpl import android.content.Context import android.net.Uri +import com.spoony.spoony.core.network.ContentUriRequestBody import com.spoony.spoony.data.datasource.CategoryDataSource import com.spoony.spoony.data.datasource.PlaceDataSource import com.spoony.spoony.data.dto.request.RegisterPostRequestDto @@ -12,9 +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 kotlin.system.measureTimeMillis +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody +import timber.log.Timber class RegisterRepositoryImpl @Inject constructor( private val placeDataSource: PlaceDataSource, @@ -51,31 +57,42 @@ class RegisterRepositoryImpl @Inject constructor( menuList: List, photos: List ): Result = runCatching { - // 1. Request DTO를 RequestBody로 변환 - val requestDto = RegisterPostRequestDto( - userId = userId, - title = title, - description = description, - placeName = placeName, - placeAddress = placeAddress, - placeRoadAddress = placeRoadAddress, - latitude = latitude, - longitude = longitude, - categoryId = categoryId, - menuList = menuList - ) - - 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") + 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 = asyncRequestBody.await(), + photos = asyncPhotoParts.awaitAll() + ).data + } + Timber.d("전체 업로드 소요 시간: $totalTime ms") } + } - postService.registerPost( - data = requestBody, - photos = photoParts - ).data + companion object { + private const val MEDIA_TYPE_JSON = "application/json" + private const val FORM_DATA_NAME_PHOTOS = "photos" } }