diff --git a/app/src/main/java/com/poti/android/core/common/constant/ImageConstants.kt b/app/src/main/java/com/poti/android/core/common/constant/ImageConstants.kt new file mode 100644 index 00000000..4453fe1c --- /dev/null +++ b/app/src/main/java/com/poti/android/core/common/constant/ImageConstants.kt @@ -0,0 +1,6 @@ +package com.poti.android.core.common.constant + +object ImageConstants { + const val IMAGE_EXTENSION = "jpg" + const val IMAGE_CONTENT_TYPE = "image/jpeg" +} diff --git a/app/src/main/java/com/poti/android/data/di/S3UploadClient.kt b/app/src/main/java/com/poti/android/data/di/FileUploadClient.kt similarity index 77% rename from app/src/main/java/com/poti/android/data/di/S3UploadClient.kt rename to app/src/main/java/com/poti/android/data/di/FileUploadClient.kt index 37052715..5f0afcb7 100644 --- a/app/src/main/java/com/poti/android/data/di/S3UploadClient.kt +++ b/app/src/main/java/com/poti/android/data/di/FileUploadClient.kt @@ -4,4 +4,4 @@ import javax.inject.Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) -annotation class S3UploadClient +annotation class FileUploadClient diff --git a/app/src/main/java/com/poti/android/data/di/NetworkModule.kt b/app/src/main/java/com/poti/android/data/di/NetworkModule.kt index d517fa50..74a4c0ea 100644 --- a/app/src/main/java/com/poti/android/data/di/NetworkModule.kt +++ b/app/src/main/java/com/poti/android/data/di/NetworkModule.kt @@ -70,7 +70,7 @@ object NetworkModule { .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() - @S3UploadClient + @FileUploadClient @Provides @Singleton fun provideOkHttpClientForS3(): OkHttpClient = OkHttpClient.Builder().build() diff --git a/app/src/main/java/com/poti/android/data/di/RepositoryModule.kt b/app/src/main/java/com/poti/android/data/di/RepositoryModule.kt index 8b60de05..24385f6f 100644 --- a/app/src/main/java/com/poti/android/data/di/RepositoryModule.kt +++ b/app/src/main/java/com/poti/android/data/di/RepositoryModule.kt @@ -3,6 +3,7 @@ package com.poti.android.data.di import com.poti.android.data.repository.ArtistRepositoryImpl import com.poti.android.data.repository.AuthRepositoryImpl import com.poti.android.data.repository.DeliveryRepositoryImpl +import com.poti.android.data.repository.FileUploadRepositoryImpl import com.poti.android.data.repository.HomeRepositoryImpl import com.poti.android.data.repository.ImageRepositoryImpl import com.poti.android.data.repository.ParticipationRepositoryImpl @@ -14,6 +15,7 @@ import com.poti.android.data.repository.UserRepositoryImpl import com.poti.android.domain.repository.ArtistRepository import com.poti.android.domain.repository.AuthRepository import com.poti.android.domain.repository.DeliveryRepository +import com.poti.android.domain.repository.FileUploadRepository import com.poti.android.domain.repository.HomeRepository import com.poti.android.domain.repository.ImageRepository import com.poti.android.domain.repository.ParticipationRepository @@ -74,4 +76,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindReviewRepository(reviewRepositoryImpl: ReviewRepositoryImpl): ReviewRepository + + @Binds + @Singleton + abstract fun bindFileUploadRepository(fileUploadRepositoryImpl: FileUploadRepositoryImpl): FileUploadRepository } diff --git a/app/src/main/java/com/poti/android/data/local/datasource/FileLocalDataSource.kt b/app/src/main/java/com/poti/android/data/local/datasource/FileLocalDataSource.kt new file mode 100644 index 00000000..b3871964 --- /dev/null +++ b/app/src/main/java/com/poti/android/data/local/datasource/FileLocalDataSource.kt @@ -0,0 +1,91 @@ +package com.poti.android.data.local.datasource + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.util.Size +import androidx.core.net.toUri +import com.poti.android.core.common.constant.ImageConstants.IMAGE_EXTENSION +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.UUID +import javax.inject.Inject +import kotlin.math.max + +class FileLocalDataSource @Inject constructor( + @param:ApplicationContext private val context: Context, +) { + fun createImageFile(uriString: String): File { + val uri = uriString.toUri() + + val directory = getDirectory() + val compressedImage = compressImage(uri, directory) + + return compressedImage + } + + fun clearDirectory() { + val directory = getDirectory() + directory.listFiles()?.forEach { it.delete() } + } + + private fun getDirectory(): File = File(context.cacheDir, DIRECTORY).apply { + mkdirs() + } + + private fun compressImage( + uri: Uri, + dir: File, + ): File { + val source = ImageDecoder.createSource(context.contentResolver, uri) + val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + // 디코더 설정 + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.isMutableRequired = true + + // 리사이징 크기 설정 + val targetSize = calculateTargetSize(info.size.width, info.size.height) + decoder.setTargetSize(targetSize.width, targetSize.height) + } + + // bitmap을 리사이징(압축)한 jpeg byteArray 생성 + val compressedImage = ByteArrayOutputStream().use { stream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, QUALITY, stream) + bitmap.recycle() + stream.toByteArray() + } + + // 임시 파일 객체 생성 + val tempFile = File( + dir, + "${UUID.randomUUID()}.$IMAGE_EXTENSION", + ) + + // 파일 객채에 리사이징 byteArray 덮어씌워 최종 이미지 파일 생성 + tempFile.outputStream().use { stream -> + stream.write(compressedImage) + } + + return tempFile + } + + private fun calculateTargetSize( + width: Int, + height: Int, + ): Size { + if (width <= MAX_WIDTH && height <= MAX_HEIGHT) return Size(width, height) + + val ratio = max(width.toFloat() / MAX_WIDTH, height.toFloat() / MAX_HEIGHT) + + return Size((width / ratio).toInt(), (height / ratio).toInt()) + } + + companion object { + private const val MAX_WIDTH = 1024 + private const val MAX_HEIGHT = 1024 + private const val QUALITY = 80 + private const val DIRECTORY = "compressed" + } +} diff --git a/app/src/main/java/com/poti/android/data/remote/datasource/FileUploadRemoteDataSource.kt b/app/src/main/java/com/poti/android/data/remote/datasource/FileUploadRemoteDataSource.kt new file mode 100644 index 00000000..770967b0 --- /dev/null +++ b/app/src/main/java/com/poti/android/data/remote/datasource/FileUploadRemoteDataSource.kt @@ -0,0 +1,37 @@ +package com.poti.android.data.remote.datasource + +import com.poti.android.core.common.constant.ImageConstants.IMAGE_CONTENT_TYPE +import com.poti.android.data.di.FileUploadClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import javax.inject.Inject + +class FileUploadRemoteDataSource @Inject constructor( + @param:FileUploadClient private val okHttpClient: OkHttpClient, +) { + suspend fun uploadImage( + uploadUrl: String, + file: File, + ) = withContext(Dispatchers.IO) { + val requestBody = file.asRequestBody(IMAGE_CONTENT_TYPE.toMediaType()) + + val request = Request.Builder() + .url(uploadUrl) + .put(requestBody) + .header("Content-Type", IMAGE_CONTENT_TYPE) + .build() + + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException( + "File upload failed: ${response.code}", + ) + } + } + } +} diff --git a/app/src/main/java/com/poti/android/data/repository/FileUploadRepositoryImpl.kt b/app/src/main/java/com/poti/android/data/repository/FileUploadRepositoryImpl.kt new file mode 100644 index 00000000..ad774af9 --- /dev/null +++ b/app/src/main/java/com/poti/android/data/repository/FileUploadRepositoryImpl.kt @@ -0,0 +1,39 @@ +package com.poti.android.data.repository + +import com.poti.android.core.network.util.HttpResponseHandler +import com.poti.android.data.local.datasource.FileLocalDataSource +import com.poti.android.data.remote.datasource.FileUploadRemoteDataSource +import com.poti.android.domain.repository.FileUploadRepository +import java.io.File +import javax.inject.Inject + +class FileUploadRepositoryImpl @Inject constructor( + private val httpResponseHandler: HttpResponseHandler, + private val fileUploadRemoteDataSource: FileUploadRemoteDataSource, + private val fileLocalDataSource: FileLocalDataSource, +) : FileUploadRepository { + override suspend fun uploadImage( + uploadUrl: String, + file: File, + ): Result = httpResponseHandler.safeApiCall { + fileUploadRemoteDataSource.uploadImage(uploadUrl, file) + } + + override fun createImage(uriString: String): Result { + try { + val file = fileLocalDataSource.createImageFile(uriString) + return Result.success(file) + } catch (exception: Throwable) { + return Result.failure(exception) + } + } + + override fun clearDirectory(): Result { + try { + fileLocalDataSource.clearDirectory() + return Result.success(Unit) + } catch (exception: Throwable) { + return Result.failure(exception) + } + } +} diff --git a/app/src/main/java/com/poti/android/data/repository/S3RepositoryImpl.kt b/app/src/main/java/com/poti/android/data/repository/S3RepositoryImpl.kt index 7c2e7fd7..bc7b967d 100644 --- a/app/src/main/java/com/poti/android/data/repository/S3RepositoryImpl.kt +++ b/app/src/main/java/com/poti/android/data/repository/S3RepositoryImpl.kt @@ -1,7 +1,7 @@ package com.poti.android.data.repository import com.poti.android.core.network.util.HttpResponseHandler -import com.poti.android.data.di.S3UploadClient +import com.poti.android.data.di.FileUploadClient import com.poti.android.domain.model.image.PresignedUploadInfo import com.poti.android.domain.repository.S3Repository import okhttp3.MediaType.Companion.toMediaType @@ -13,7 +13,7 @@ import javax.inject.Inject class S3RepositoryImpl @Inject constructor( private val httpResponseHandler: HttpResponseHandler, - @param:S3UploadClient private val okHttpClient: OkHttpClient, + @param:FileUploadClient private val okHttpClient: OkHttpClient, ) : S3Repository { override suspend fun uploadImages( uploadInfos: List, diff --git a/app/src/main/java/com/poti/android/domain/repository/FileUploadRepository.kt b/app/src/main/java/com/poti/android/domain/repository/FileUploadRepository.kt new file mode 100644 index 00000000..c2da0bed --- /dev/null +++ b/app/src/main/java/com/poti/android/domain/repository/FileUploadRepository.kt @@ -0,0 +1,14 @@ +package com.poti.android.domain.repository + +import java.io.File + +interface FileUploadRepository { + suspend fun uploadImage( + uploadUrl: String, + file: File, + ): Result + + fun createImage(uriString: String): Result + + fun clearDirectory(): Result +} diff --git a/app/src/main/java/com/poti/android/domain/usecase/image/UploadImagesUseCaseV2.kt b/app/src/main/java/com/poti/android/domain/usecase/image/UploadImagesUseCaseV2.kt new file mode 100644 index 00000000..a876d892 --- /dev/null +++ b/app/src/main/java/com/poti/android/domain/usecase/image/UploadImagesUseCaseV2.kt @@ -0,0 +1,61 @@ +package com.poti.android.domain.usecase.image + +import com.poti.android.core.common.constant.ImageConstants.IMAGE_EXTENSION +import com.poti.android.domain.model.image.PresignedUploadInfo +import com.poti.android.domain.repository.FileUploadRepository +import com.poti.android.domain.repository.ImageRepository +import java.io.File +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +class UploadImagesUseCaseV2 @Inject constructor( + private val imageRepository: ImageRepository, + private val fileUploadRepository: FileUploadRepository, +) { + suspend operator fun invoke( + uploadType: String, + uriStrings: List, + ): Result> { + try { + val uploadInfos = getUploadUrls(uploadType, uriStrings.size) + val (urls, fileNames) = uploadInfos.map { it.url to it.fileName }.unzip() + val files = createImages(uriStrings) + + uploadImages(urls, files) + + return Result.success(fileNames) + } catch (t: Throwable) { + if (t is CancellationException) throw t + return Result.failure(t) + } finally { + fileUploadRepository.clearDirectory() + } + } + + private suspend fun getUploadUrls( + uploadType: String, + size: Int, + ): List = imageRepository.getPresignedUrls( + type = uploadType, + extensions = List(size) { IMAGE_EXTENSION }, + ).getOrThrow() + + private fun createImages( + uriStrings: List, + ): List = uriStrings.map { uri -> + fileUploadRepository.createImage(uri).getOrThrow() + } + + private suspend fun uploadImages( + urls: List, + files: List, + ) { + if (urls.size != files.size) { + throw IllegalStateException("Upload URL count and file count must match") + } + + for (i in urls.indices) { + fileUploadRepository.uploadImage(urls[i], files[i]).getOrThrow() + } + } +}