Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,7 @@ fun TimeLetterBlockList(
key = { it.id }
) { letter ->
TimeLetterBlockItem(
receiverName = letter.receivername,
sendDate = letter.sendDate,
title = letter.title,
content = letter.content,
imageResId = letter.imageResId,
theme = letter.theme,
timeLetter = letter,
onClick = { onItemClick(letter) }
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,7 @@ fun ReceiverTimeLetterListScreen(
key = { item -> item.id }
) { item ->
TimeLetterBlockItem(
receiverName = item.receivername,
sendDate = item.sendDate,
title = item.title,
content = item.content,
imageResId = item.imageResId,
theme = item.theme,
timeLetter = item,
onClick = { onItemClick(item) }
)
Spacer(modifier = Modifier.height(16.dp))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.kuit.afternote.feature.timeletter.data.di

import com.kuit.afternote.feature.timeletter.data.api.TimeLetterApiService
import com.kuit.afternote.feature.timeletter.data.repository.TimeLetterMediaUploadRepositoryImpl
import com.kuit.afternote.feature.timeletter.data.repository.TimeLetterRepositoryImpl
import com.kuit.afternote.feature.timeletter.domain.repository.TimeLetterMediaUploadRepository
import com.kuit.afternote.feature.timeletter.domain.repository.TimeLetterRepository
import dagger.Binds
import dagger.Module
Expand All @@ -18,9 +20,16 @@ abstract class TimeLetterRepositoryModule {
@Singleton
abstract fun bindTimeLetterRepository(impl: TimeLetterRepositoryImpl): TimeLetterRepository

@Binds
@Singleton
abstract fun bindTimeLetterMediaUploadRepository(
impl: TimeLetterMediaUploadRepositoryImpl
): TimeLetterMediaUploadRepository

companion object {
@Provides
@Singleton
fun provideTimeLetterApiService(retrofit: Retrofit): TimeLetterApiService = retrofit.create(TimeLetterApiService::class.java)
fun provideTimeLetterApiService(retrofit: Retrofit): TimeLetterApiService =
retrofit.create(TimeLetterApiService::class.java)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ enum class TimeLetterStatus {
enum class TimeLetterMediaType {
IMAGE,
VIDEO,
AUDIO
AUDIO,
DOCUMENT
}

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ object TimeLetterMapper {
DtoMediaType.IMAGE -> DomainMediaType.IMAGE
DtoMediaType.VIDEO -> DomainMediaType.VIDEO
DtoMediaType.AUDIO -> DomainMediaType.AUDIO
DtoMediaType.DOCUMENT -> DomainMediaType.DOCUMENT
}

private fun toDomainMedia(dto: TimeLetterMediaResponse): TimeLetterMedia =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.kuit.afternote.feature.timeletter.data.repository

import android.content.Context
import android.net.Uri
import com.kuit.afternote.data.remote.requireData
import com.kuit.afternote.feature.timeletter.domain.repository.TimeLetterMediaUploadRepository
import com.kuit.afternote.feature.user.data.api.ImageApiService
import com.kuit.afternote.feature.user.data.dto.PresignedUrlRequestDto
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import javax.inject.Inject
import javax.inject.Named

/**
* Presigned URL을 받아 타임레터 첨부 이미지를 S3에 업로드하고 fileUrl을 반환합니다.
*
* directory는 백엔드와 합의한 값 사용. 미지원 시 afternotes 등 기존 디렉터리로 변경 가능.
*/
private const val DIRECTORY_TIME_LETTERS = "timeletters"
private const val DEFAULT_EXTENSION = "jpg"

class TimeLetterMediaUploadRepositoryImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
private val imageApi: ImageApiService,
@Named("S3Upload") private val okHttpClient: OkHttpClient,
@Named("IoDispatcher") private val ioDispatcher: CoroutineDispatcher
) : TimeLetterMediaUploadRepository {

override suspend fun uploadImage(uriString: String): Result<String> =
runCatching {
val uri = Uri.parse(uriString)
val extension = extensionFromUri(uri)
val presigned = imageApi.getPresignedUrl(
PresignedUrlRequestDto(
directory = DIRECTORY_TIME_LETTERS,
extension = extension
)
).requireData()
?: throw IllegalStateException("Presigned URL response data is null")

val bytes = withContext(ioDispatcher) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalStateException("Could not read image from URI")
}

val contentType = presigned.contentType.ifBlank { "image/jpeg" }
val requestBody = bytes.toRequestBody(contentType.toMediaType())
val putRequest =
Request.Builder()
.url(presigned.presignedUrl)
.put(requestBody)
.header("Content-Type", contentType)
.build()

withContext(ioDispatcher) {
okHttpClient.newCall(putRequest).execute().use { response ->
check(response.isSuccessful) {
"S3 upload failed: ${response.code} ${response.message}"
}
}
}
presigned.fileUrl
}

private fun extensionFromUri(uri: Uri): String {
val mime = context.contentResolver.getType(uri) ?: return DEFAULT_EXTENSION
return when {
mime == "image/jpeg" || mime == "image/jpg" -> "jpg"
mime == "image/png" -> "png"
mime == "image/webp" -> "webp"
mime == "image/gif" -> "gif"
mime == "image/heic" -> "heic"
else -> DEFAULT_EXTENSION
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class TimeLetterRepositoryImpl
DomainMediaType.IMAGE -> DtoMediaType.IMAGE
DomainMediaType.VIDEO -> DtoMediaType.VIDEO
DomainMediaType.AUDIO -> DtoMediaType.AUDIO
DomainMediaType.DOCUMENT -> DtoMediaType.DOCUMENT
}
TimeLetterMediaRequest(mediaType = dtoType, mediaUrl = url)
}
Expand Down Expand Up @@ -102,6 +103,7 @@ class TimeLetterRepositoryImpl
DomainMediaType.IMAGE -> DtoMediaType.IMAGE
DomainMediaType.VIDEO -> DtoMediaType.VIDEO
DomainMediaType.AUDIO -> DtoMediaType.AUDIO
DomainMediaType.DOCUMENT -> DtoMediaType.DOCUMENT
}
TimeLetterMediaRequest(mediaType = dtoType, mediaUrl = url)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ enum class TimeLetterStatus {
enum class TimeLetterMediaType {
IMAGE,
VIDEO,
AUDIO
AUDIO,
DOCUMENT
}

data class TimeLetterMedia(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.kuit.afternote.feature.timeletter.domain.repository

/**
* 타임레터 첨부 이미지 업로드 (POST /files/presigned-url 후 S3 PUT).
*
* content URI 문자열을 받아 업로드 후 사용할 fileUrl을 반환합니다.
*/
fun interface TimeLetterMediaUploadRepository {

/**
* [uriString]에 해당하는 이미지를 S3에 업로드하고, 타임레터 mediaList에 넣을 fileUrl을 반환합니다.
*
* @param uriString content URI 문자열 (예: content://media/...)
* @return 성공 시 업로드된 이미지 URL, 실패 시 Result.failure
*/
suspend fun uploadImage(uriString: String): Result<String>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.kuit.afternote.feature.timeletter.domain.usecase

import com.kuit.afternote.feature.timeletter.domain.repository.TimeLetterMediaUploadRepository
import javax.inject.Inject

/**
* 타임레터 첨부 이미지 1장을 presigned URL로 S3에 업로드하고 fileUrl을 반환합니다.
*
* @param uriString content URI 문자열
* @return 성공 시 fileUrl, 실패 시 Result.failure
*/
class UploadTimeLetterImageUseCase
@Inject
constructor(
private val repository: TimeLetterMediaUploadRepository
) {
suspend operator fun invoke(uriString: String): Result<String> =
repository.uploadImage(uriString)
}
Loading
Loading