Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
@@ -1,12 +1,14 @@
package com.alarmy.near.data.mapper

import com.alarmy.near.local.contact.ContactImageData
import com.alarmy.near.model.Anniversary
import com.alarmy.near.model.ContactFrequency
import com.alarmy.near.model.DayOfWeek
import com.alarmy.near.model.Friend
import com.alarmy.near.model.Relation
import com.alarmy.near.model.ReminderInterval
import com.alarmy.near.network.request.ContactFrequencyInitRequest
import com.alarmy.near.network.request.ImageUploadRequest
import com.alarmy.near.network.request.FriendInitItemRequest
import com.alarmy.near.network.response.AnniversaryInitEntity
import com.alarmy.near.network.response.ContactFrequencyInitEntity
Expand All @@ -27,15 +29,18 @@ private fun String.formatPhoneNumber(): String = PhoneNumberFormatter.formatPhon
/**
* UI 모델을 서버 요청 모델로 변환
*/
fun FriendContactUIModel.toFriendInitItemRequest(providerType: String): FriendInitItemRequest =
fun FriendContactUIModel.toFriendInitItemRequest(
providerType: String,
imageUploadRequest: ImageUploadRequest?,
): FriendInitItemRequest =
FriendInitItemRequest(
name = name,
phone = phones.firstOrNull()?.formatPhoneNumber() ?: "",
memo = memo,
birthDay = birthDay,
source = providerType,
contactFrequency = createContactFrequencyRequest(reminderInterval!!),
imageUploadRequest = null,
imageUploadRequest = imageUploadRequest,
anniversary = null,
relation = "FRIEND", // 기본값으로 FRIEND 설정
)
Expand Down Expand Up @@ -102,3 +107,11 @@ private fun String.toRelation(): Relation =
.onFailure { exception ->
NearLog.w("잘못된 관계 값: '$this', 기본값(FRIEND) 사용")
}.getOrDefault(Relation.FRIEND)

fun ContactImageData.toImageUploadRequest(category: String): ImageUploadRequest =
ImageUploadRequest(
fileName = fileName,
contentType = contentType,
fileSize = fileSize,
category = category,
)
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package com.alarmy.near.data.repository

import com.alarmy.near.data.mapper.toFriendInitItemRequest
import com.alarmy.near.data.mapper.toImageUploadRequest
import com.alarmy.near.data.mapper.toModel
import com.alarmy.near.data.mapper.toRequest
import com.alarmy.near.local.contact.ContactImageData
import com.alarmy.near.local.contact.ContactImageReader
import com.alarmy.near.model.Friend
import com.alarmy.near.model.FriendRecord
import com.alarmy.near.model.friendsummary.FriendSummary
import com.alarmy.near.model.monthly.MonthlyFriend
import com.alarmy.near.network.request.FriendInitItemRequest
import com.alarmy.near.network.request.FriendInitRequest
import com.alarmy.near.network.response.FriendInitItemEntity
import com.alarmy.near.network.service.FriendService
import com.alarmy.near.network.uploader.ImageUploader
import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel
import com.alarmy.near.utils.extensions.apiCallFlow
import kotlinx.coroutines.flow.Flow
Expand All @@ -20,6 +25,8 @@ class DefaultFriendRepository
@Inject
constructor(
private val friendService: FriendService,
private val contactImageReader: ContactImageReader,
private val imageUploader: ImageUploader,
) : FriendRepository {
override fun fetchFriends(): Flow<List<FriendSummary>> =
flow {
Expand Down Expand Up @@ -79,16 +86,43 @@ class DefaultFriendRepository
providerType: String,
): Flow<List<FriendInitItemEntity>> =
apiCallFlow {
// UI 모델을 Data 모델로 변환
val friendInitRequest =
FriendInitRequest(
friendList =
contacts
.filter { it.reminderInterval != null }
.map { it.toFriendInitItemRequest(providerType) },
)

// 서버 요청 및 응답 반환
friendService.initFriends(friendInitRequest).friendList
val payloads =
contacts
.filter { it.reminderInterval != null }
.map { contact ->
val imageData = contact.photoUri?.let { uri -> contactImageReader.read(uri) }
val request =
contact.toFriendInitItemRequest(
providerType = providerType,
imageUploadRequest = imageData?.toImageUploadRequest(PROFILE_IMAGE_CATEGORY),
)
FriendInitRequestPayload(
request = request,
imageData = imageData,
)
}
val friendInitRequest = FriendInitRequest(friendList = payloads.map { it.request })
val response = friendService.initFriends(friendInitRequest)
response.friendList.forEachIndexed { index, entity ->
val uploadUrl = entity.preSignedImageUrl
val imageData = payloads.getOrNull(index)?.imageData
if (uploadUrl != null && imageData != null) {
imageUploader.upload(
url = uploadUrl,
contentType = imageData.contentType,
data = imageData.data,
)
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 이미지 업로드는 forEachIndexed 루프 내에서 순차적으로 실행됩니다. 연락처에 이미지가 있는 친구가 많을 경우, 모든 이미지가 하나씩 업로드될 때까지 기다려야 하므로 전체 프로세스가 느려질 수 있습니다. coroutineScopelaunch를 사용하여 이미지 업로드를 병렬로 처리하면 성능을 개선할 수 있습니다. 이렇게 하면 여러 이미지를 동시에 업로드하여 전체 대기 시간을 줄일 수 있습니다. 만약 업로드 중 하나라도 실패하면 coroutineScope가 나머지 업로드를 취소하고 예외를 전파하므로, 기존의 동작 방식도 유지됩니다.

(참고: kotlinx.coroutines.coroutineScopekotlinx.coroutines.launch를 import해야 합니다.)

                coroutineScope {
                    response.friendList.forEachIndexed { index, entity ->
                        val uploadUrl = entity.preSignedImageUrl
                        val imageData = payloads.getOrNull(index)?.imageData
                        if (uploadUrl != null && imageData != null) {
                            launch {
                                imageUploader.upload(
                                    url = uploadUrl,
                                    contentType = imageData.contentType,
                                    data = imageData.data,
                                )
                            }
                        }
                    }
                }

response.friendList
}

private data class FriendInitRequestPayload(
val request: FriendInitItemRequest,
val imageData: ContactImageData?,
)

companion object {
private const val PROFILE_IMAGE_CATEGORY = "PROFILE"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.alarmy.near.local.contact

import android.content.ContentResolver
import android.net.Uri
import android.webkit.MimeTypeMap
import javax.inject.Inject

data class ContactImageData(
val fileName: String,
val contentType: String,
val fileSize: Int,
val data: ByteArray,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

byteArray가 값이 다르면 다른 객체로 인식해서 equals 및 hashCode를 재정의하라는 경고가 뜨네요!

해결 방법으로 option+ enter로 해당 코드를 자동으로 추가할 수 있습니다! 코드가 복잡해진다면 필요한 곳에서 byteArray를 맵핑해주는 방법이 있을 것 같아요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다! equals, hashCode를 재정의 하는 코드가 크게 복잡하지 않아 우선 재정의 하는 방법으로 개선해보았습니다!

)

class ContactImageReader
@Inject
constructor(
private val contentResolver: ContentResolver,
) {
fun read(uriString: String): ContactImageData? {
val uri = runCatching { Uri.parse(uriString) }.getOrNull() ?: return null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거 제 환경에서는 아래처럼 바꿔라고 경고가 뜨는데, 지석님은 혹시 해당 현상 없으셨을까요?

uriString.toUri() 

val bytes = contentResolver.openInputStream(uri)?.use { inputStream -> inputStream.readBytes() } ?: return null
val resolvedMimeType = contentResolver.getType(uri) ?: guessMimeType(uriString) ?: DEFAULT_MIME_TYPE
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(resolvedMimeType) ?: DEFAULT_EXTENSION
val fileName = "contact_${System.currentTimeMillis()}.$extension"
return ContactImageData(
fileName = fileName,
contentType = resolvedMimeType,
fileSize = bytes.size,
data = bytes,
)
}

private fun guessMimeType(uriString: String): String? {
val lowerCase = uriString.lowercase()
return when {
lowerCase.endsWith(".png") -> "image/png"
lowerCase.endsWith(".webp") -> "image/webp"
lowerCase.endsWith(".jpg") || lowerCase.endsWith(".jpeg") -> "image/jpeg"
else -> null
}
}

companion object {
private const val DEFAULT_MIME_TYPE = "image/jpeg"
private const val DEFAULT_EXTENSION = "jpg"
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.alarmy.near.network.uploader

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ImageUploader
@Inject
constructor() {
private val okHttpClient = OkHttpClient()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

ImageUploader 내에서 OkHttpClient를 직접 생성하고 있습니다. 앱 전체에서 OkHttpClient 인스턴스를 공유하면 커넥션 풀링, 캐시, 타임아웃 설정 등 리소스를 효율적으로 관리할 수 있습니다. Hilt를 통해 이미 설정된 OkHttpClient를 주입받아 사용하는 것이 좋습니다. 이렇게 하면 네트워크 관련 설정을 한 곳에서 관리할 수 있고, ImageUploader가 앱의 다른 네트워크 요청과 동일한 설정을 공유하게 됩니다.

Suggested change
class ImageUploader
@Inject
constructor() {
private val okHttpClient = OkHttpClient()
class ImageUploader
@Inject
constructor(private val okHttpClient: OkHttpClient) {


suspend fun upload(
url: String,
contentType: String,
data: ByteArray,
) = withContext(Dispatchers.IO) {
val requestBody = data.toRequestBody(contentType.toMediaTypeOrNull())
val request =
Request
.Builder()
.url(url)
.put(requestBody)
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사소한 것인데
response.isSuccessful.not()으로 가독성을 올릴 수 있다고 생각해요!

반영은 안하셔도 됩니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

디테일한 코멘트 감사합니다!
궁금한 것이 .not() 함수를 사용하는 것이 가독성이 더 좋아보이는 편인가요?
저는 오히려 코드가 길어질 수 있다고 생각하여 ! 연산자를 활용하는 편인데 어떻게 생각하시나요?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인 취향인데, 저는 자연어처럼 코드가 읽히는 것을 선호해서 not을 꽤 쓰는 편인 것 같아요!
함수명이 길어지는 것, 복잡한 필드 선언시에 타입을 명시하는 것처럼 가독성 이점의 맥락으로 쓰고 있습니다!

throw IllegalStateException("이미지 업로드에 실패했습니다.")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

이미지 업로드 실패 시 던지는 IllegalStateException에 더 자세한 오류 정보를 포함하면 디버깅에 도움이 됩니다. HTTP 응답 코드와 메시지를 예외 메시지에 추가하는 것을 고려해보세요.

Suggested change
throw IllegalStateException("이미지 업로드에 실패했습니다.")
throw IllegalStateException("이미지 업로드에 실패했습니다. Code: ${response.code}, Message: ${response.message}")

}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
Expand All @@ -28,6 +29,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.alarmy.near.R
import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel
import com.alarmy.near.presentation.ui.extension.ImageLoader
import com.alarmy.near.presentation.ui.extension.onNoRippleClick
import com.alarmy.near.presentation.ui.theme.NearTheme

Expand Down Expand Up @@ -116,10 +118,15 @@ fun FriendListItem(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
Image(
painter = painterResource(R.drawable.img_64_user_gray),
ImageLoader(
uri = contact.photoUri,
modifier =
Modifier
.size(24.dp)
.clip(CircleShape),
placeholder = R.drawable.img_64_user_gray,
error = R.drawable.img_64_user_gray,
contentDescription = null,
modifier = Modifier.size(24.dp),
)

Spacer(modifier = Modifier.size(12.dp))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
Expand All @@ -39,6 +41,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
Expand Down Expand Up @@ -70,6 +73,7 @@ import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar
import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton
import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenu
import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenuItem
import com.alarmy.near.presentation.ui.extension.ImageLoader
import com.alarmy.near.presentation.ui.extension.onNoRippleClick
import com.alarmy.near.presentation.ui.theme.NearTheme
import kotlinx.coroutines.delay
Expand Down Expand Up @@ -248,11 +252,20 @@ fun FriendProfileScreen(
.padding(horizontal = 32.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
// 이미지 + 이모지
Image(
modifier = Modifier.align(Alignment.Center),
painter = painterResource(R.drawable.img_80_user1),
Box(
modifier =
Modifier
.size(80.dp),
contentAlignment = Alignment.Center,
) {
ImageLoader(
uri = friend.imageUrl,
modifier =
Modifier
.matchParentSize()
.clip(CircleShape),
placeholder = R.drawable.img_80_user1,
error = R.drawable.img_80_user1,
contentDescription = null,
)
Image(
Expand Down Expand Up @@ -345,7 +358,7 @@ fun FriendProfileScreen(
NearTheme.colors.GRAY01_888888.copy(
alpha = 0.3f,
),
selected = currentTabPosition.intValue == 0,
selected = currentTabPosition.intValue == 0,
onClick = { currentTabPosition.intValue = 0 },
) {
Text(
Expand Down Expand Up @@ -375,9 +388,10 @@ fun FriendProfileScreen(
.height(50.dp),
selected = currentTabPosition.intValue == 1,
onClick = { currentTabPosition.intValue = 1 },
selectedContentColor = NearTheme.colors.GRAY01_888888.copy(
alpha = 0.3f
),
selectedContentColor =
NearTheme.colors.GRAY01_888888.copy(
alpha = 0.3f,
),
) {
Text(
text = stringResource(R.string.friend_profile_tab_text_record),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
package com.alarmy.near.presentation.feature.home.component

import androidx.compose.foundation.Image
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
Expand All @@ -21,6 +24,7 @@ import androidx.compose.ui.unit.dp
import com.alarmy.near.R
import com.alarmy.near.model.friendsummary.ContactFrequencyLevel
import com.alarmy.near.model.friendsummary.FriendSummary
import com.alarmy.near.presentation.ui.extension.ImageLoader
import com.alarmy.near.presentation.ui.extension.onNoRippleClick
import com.alarmy.near.presentation.ui.theme.NearTheme

Expand All @@ -40,8 +44,14 @@ fun ContactItem(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box {
Image(
painter = painterResource(R.drawable.img_64_user1),
ImageLoader(
uri = friendSummary.profileImageUrl,
modifier =
Modifier
.size(64.dp)
.clip(CircleShape),
placeholder = R.drawable.img_64_user1,
error = R.drawable.img_64_user1,
contentDescription = "",
)
Image(
Expand Down