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 @@ -3,6 +3,7 @@ package com.afternote.feature.receiver.presentation.detail
import com.afternote.feature.afternote.domain.AfternoteType
import com.afternote.feature.afternote.presentation.shared.model.AlbumCover
import com.afternote.feature.afternote.presentation.shared.model.toMessageBlockUiModels
import com.afternote.feature.receiver.domain.model.ReceivedAccountCredentials
import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail

internal fun ReceivedAfternoteDetail.toReceivedDetailContentUiModel(): ReceivedDetailContentUiModel =
Expand Down Expand Up @@ -32,13 +33,29 @@ internal fun ReceivedAfternoteDetail.toReceivedDetailContentUiModel(): ReceivedD
private fun ReceivedAfternoteDetail.toReceivedSocialNetworkDetailContent(): ReceivedSocialNetworkDetailContent =
ReceivedSocialNetworkDetailContent(
serviceName = title,
accountId = credentials?.id.orEmpty(),
password = credentials?.password.orEmpty(),
credentials = credentials.toUiModelOrNull(),
processingMethods = processingMethods,
messageBlocks = leaveMessageBlocks.toMessageBlockUiModels(),
finalWriteDate = createdAt.orEmpty(),
)

/**
* 계정 자격증명을 표시 모델로 옮긴다.
*
* 공백뿐인 값은 미제공과 구분할 수 없으므로 `null` 로 접고, 그러고도 남는 값이 없으면 모델 자체를
* 만들지 않는다. 예전처럼 `orEmpty()` 로 뭉개면 화면이 빈 비밀번호에 마스킹을 그려, 수신자가
* 가려진 값이 있다고 오인한 채 "표시" 를 눌러도 아무것도 얻지 못했다 (#619).
*/
private fun ReceivedAccountCredentials?.toUiModelOrNull(): ReceivedAccountCredentialsUiModel? {
val accountId = this?.id?.takeIf(String::isNotBlank)
val password = this?.password?.takeIf(String::isNotBlank)
return if (accountId == null && password == null) {
null
} else {
ReceivedAccountCredentialsUiModel(accountId = accountId, password = password)
}
}

private fun ReceivedAfternoteDetail.toReceivedGalleryDetailContent(): ReceivedGalleryDetailContent =
ReceivedGalleryDetailContent(
serviceName = title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,28 @@ sealed interface ReceivedDetailContentUiModel {
@Immutable
data class ReceivedSocialNetworkDetailContent(
val serviceName: String = "",
val accountId: String = "",
val password: String = "",
val credentials: ReceivedAccountCredentialsUiModel? = null,
val processingMethods: List<String> = emptyList(),
val messageBlocks: List<MessageBlockUiModel> = emptyList(),
val finalWriteDate: String = "",
)

/**
* 발신자가 남긴 계정 자격증명 표시 모델.
*
* **부재를 빈 문자열로 표현하지 않는다.** 서버는 수신자 상세에 `credentials` 를 아예 내려주지
* 않고(#619), 내려주더라도 발신자가 한쪽만 적었을 수 있다. 값이 없는 자리를 `""` 로 채우면
* 화면이 "가려진 값이 있다" 는 마스킹을 그리게 되므로, 없는 값은 `null` 로 남긴다.
*
* 양쪽 모두 없으면 이 모델을 만들지 않고 [ReceivedSocialNetworkDetailContent.credentials] 를
* `null` 로 둔다 — 이 타입이 존재하면 최소 한쪽에는 값이 있다는 뜻이다.
*/
@Immutable
data class ReceivedAccountCredentialsUiModel(
val accountId: String?,
val password: String?,
)

@Immutable
data class ReceivedGalleryDetailContent(
val serviceName: String = "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import com.afternote.feature.afternote.presentation.shared.detail.MessageSection
import com.afternote.feature.afternote.presentation.shared.detail.ProcessingMethodsSection
import com.afternote.feature.afternote.presentation.shared.model.AfternoteServiceDisplay
import com.afternote.feature.afternote.presentation.shared.model.MessageBlockUiModel
import com.afternote.feature.receiver.presentation.R as ReceiverR

/**
* 수신 소셜 네트워크 상세 (Stateless).
Expand Down Expand Up @@ -92,10 +93,7 @@ private fun SocialNetworkReceivedDetailScrollContent(

Spacer(modifier = Modifier.height(31.dp))
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ReceivedAccountSection(
accountId = content.accountId,
password = content.password,
)
ReceivedAccountSection(credentials = content.credentials)
ProcessingMethodsSection(methods = content.processingMethods)
MessageSection(blocks = content.messageBlocks)
}
Expand All @@ -107,64 +105,100 @@ private fun SocialNetworkReceivedDetailScrollContent(
*
* 발신자 화면의 동명 private composable과 시각적으로 동일하나, 향후 노출 정책 변경 가능성에 대비해
* receiver 화면에 별도 정의한다.
*
* [credentials] 가 `null` 이면 — 서버가 수신자에게 자격증명을 내려주지 않았거나 발신자가 적지
* 않은 경우 — 아이디·비밀번호 행 대신 부재 문구를 그린다. 빈 값에 마스킹을 씌우면 수신자는
* 가려진 값이 있다고 믿고 "표시" 를 누르지만 아무것도 얻지 못한다 (#619).
*/
@Composable
private fun ReceivedAccountSection(
accountId: String,
password: String,
credentials: ReceivedAccountCredentialsUiModel?,
modifier: Modifier = Modifier,
) {
var passwordVisible by remember { mutableStateOf(false) }

DetailSection(
iconResId = com.afternote.core.ui.R.drawable.core_ui_user,
label = stringResource(R.string.feature_afternote_detail_section_account),
modifier = modifier,
) {
if (credentials == null) {
Text(
text = stringResource(ReceiverR.string.receiver_detail_account_absent),
style = AfternoteDesign.typography.bodySmallR,
color = AfternoteDesign.colors.gray6,
)
return@DetailSection
}

Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
DetailInfoRow(
iconResId = com.afternote.core.ui.R.drawable.core_ui_user,
label = stringResource(R.string.feature_afternote_detail_label_id),
value = accountId,
value = credentials.accountId ?: stringResource(ReceiverR.string.receiver_detail_account_value_absent),
)
HorizontalDivider(
color = AfternoteDesign.colors.gray2,
thickness = 1.dp,
)
DetailInfoRow(
iconResId = R.drawable.feature_afternote_ic_lock,
label = stringResource(R.string.feature_afternote_detail_label_password),
value =
if (passwordVisible) {
password
} else {
stringResource(R.string.feature_afternote_detail_password_mask)
},
trailingContent = {
val toggleLabel =
if (passwordVisible) {
stringResource(R.string.feature_afternote_detail_password_hide)
} else {
stringResource(R.string.feature_afternote_detail_password_show)
}
Text(
text = toggleLabel,
style = AfternoteDesign.typography.captionLargeR,
color = AfternoteDesign.colors.b1,
modifier =
Modifier.clickable(
role = Role.Button,
onClickLabel = toggleLabel,
) {
passwordVisible = !passwordVisible
},
)
},
)
ReceivedPasswordRow(password = credentials.password)
}
}
}

/**
* 비밀번호 행. 값이 있을 때만 마스킹과 표시 토글을 붙인다 — 없는 값을 가리는 시늉을 하지 않는다.
*/
@Composable
private fun ReceivedPasswordRow(
password: String?,
modifier: Modifier = Modifier,
) {
val passwordLabel = stringResource(R.string.feature_afternote_detail_label_password)

if (password == null) {
DetailInfoRow(
iconResId = R.drawable.feature_afternote_ic_lock,
label = passwordLabel,
value = stringResource(ReceiverR.string.receiver_detail_account_value_absent),
modifier = modifier,
)
return
}

var passwordVisible by remember { mutableStateOf(false) }

DetailInfoRow(
iconResId = R.drawable.feature_afternote_ic_lock,
label = passwordLabel,
value =
if (passwordVisible) {
password
} else {
stringResource(R.string.feature_afternote_detail_password_mask)
},
modifier = modifier,
trailingContent = {
val toggleLabel =
if (passwordVisible) {
stringResource(R.string.feature_afternote_detail_password_hide)
} else {
stringResource(R.string.feature_afternote_detail_password_show)
}
Text(
text = toggleLabel,
style = AfternoteDesign.typography.captionLargeR,
color = AfternoteDesign.colors.b1,
modifier =
Modifier.clickable(
role = Role.Button,
onClickLabel = toggleLabel,
) {
passwordVisible = !passwordVisible
},
)
},
)
}

@Preview(showBackground = true)
@Composable
private fun SocialNetworkReceivedDetailScreenPreview() {
Expand All @@ -174,8 +208,11 @@ private fun SocialNetworkReceivedDetailScreenPreview() {
content =
ReceivedSocialNetworkDetailContent(
serviceName = "인스타그램",
accountId = "qwerty123",
password = "qwerty123!",
credentials =
ReceivedAccountCredentialsUiModel(
accountId = "qwerty123",
password = "qwerty123!",
),
processingMethods = listOf("게시물 내리기", "추모 게시물 올리기", "추모 계정으로 전환하기"),
messageBlocks =
listOf(
Expand Down
8 changes: 8 additions & 0 deletions feature/receiver/presentation/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,12 @@
-->
<string name="receiver_afternote_list_header_description">고인이 사후 정리하고자 하는 데이터입니다.</string>
<string name="receiver_playlist_song_count_format">총 %1$d곡</string>

<!--
SOCIAL 수신 상세 ACCOUNT 섹션의 부재 문구. 서버는 수신자에게 credentials 를 내려주지 않고,
내려주더라도 발신자가 한쪽만 적었을 수 있다. 빈 값에 마스킹을 그려 "가려진 값이 있다" 고
오인시키는 대신 남기지 않았다는 사실을 명시한다 (#619).
-->
<string name="receiver_detail_account_absent">고인이 계정 정보를 남기지 않았습니다.</string>
<string name="receiver_detail_account_value_absent">남기지 않음</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,66 @@ import androidx.compose.ui.tooling.preview.Preview
import com.afternote.core.ui.theme.AfternoteTheme
import com.android.tools.screenshot.PreviewTest

/**
* 자격증명 부재 — 서버가 수신자에게 `credentials` 를 내려주지 않는 실제 상태다.
*
* 예전에는 이 baseline 이 빈 아이디와 `••••••••` 마스킹을 찍었다. 가려진 값이 있다는 표시였지만
* "표시" 를 눌러도 빈 값뿐이었다 (#619).
*
* 세 baseline 은 **같은 애프터노트의 자격증명 상태만 다른 판**이다 — 서비스명을 함께 채우는 이유는
* 제목이 빈 화면이 실재하지 않기 때문이다. 제목은 에디터 검증(`TITLE_REQUIRED`)·서버
* `@NotBlank`·DB `nullable = false` 로 3중으로 강제된다.
*/
@PreviewTest
@Preview(showBackground = true)
@Composable
internal fun socialNetworkReceivedDetailScreenScreenshot() {
AfternoteTheme {
SocialNetworkReceivedDetailScreen(onBackClick = {})
SocialNetworkReceivedDetailScreen(
onBackClick = {},
content = ReceivedSocialNetworkDetailContent(serviceName = "인스타그램"),
)
}
}

/** 자격증명이 온전히 전달된 상태 — 마스킹과 표시 토글이 붙는 유일한 경우다. */
@PreviewTest
@Preview(showBackground = true)
@Composable
internal fun socialNetworkReceivedDetailScreenWithCredentialsScreenshot() {
AfternoteTheme {
SocialNetworkReceivedDetailScreen(
onBackClick = {},
content =
ReceivedSocialNetworkDetailContent(
serviceName = "인스타그램",
credentials =
ReceivedAccountCredentialsUiModel(
accountId = "qwerty123",
password = "qwerty123!",
),
),
)
}
}

/** 발신자가 아이디만 남긴 상태 — 비밀번호 자리에 마스킹 대신 부재를 명시한다. */
@PreviewTest
@Preview(showBackground = true)
@Composable
internal fun socialNetworkReceivedDetailScreenWithIdOnlyScreenshot() {
AfternoteTheme {
SocialNetworkReceivedDetailScreen(
onBackClick = {},
content =
ReceivedSocialNetworkDetailContent(
serviceName = "인스타그램",
credentials =
ReceivedAccountCredentialsUiModel(
accountId = "qwerty123",
password = null,
),
),
)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.afternote.feature.receiver.presentation.detail

import com.afternote.feature.afternote.domain.AfternoteType
import com.afternote.feature.receiver.domain.model.ReceivedAccountCredentials
import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

/**
* SOCIAL 수신 상세가 계정 자격증명의 **부재를 부재로** 옮긴다는 계약 (이슈 #619).
*
* 서버(`GET receiver-auth/after-notes/{id}`)는 수신자에게 `credentials` 를 내려주지 않는다.
* 그런데도 매퍼가 `orEmpty()` 로 빈 문자열을 채우던 탓에 화면이 빈 비밀번호에 마스킹을 그렸고,
* 수신자는 "표시" 를 눌러 빈 값을 확인하고서야 아무것도 전달되지 않았음을 알았다.
*/
class ReceivedSocialNetworkCredentialsMappingTest {
private fun detail(credentials: ReceivedAccountCredentials?) =
ReceivedAfternoteDetail(
title = "인스타그램",
type = AfternoteType.SOCIAL_NETWORK,
credentials = credentials,
)

private fun credentialsOf(detail: ReceivedAfternoteDetail): ReceivedAccountCredentialsUiModel? {
val uiModel = detail.toReceivedDetailContentUiModel()

return (uiModel as ReceivedDetailContentUiModel.SocialNetwork).content.credentials
}

@Test
fun `서버가 credentials 를 안 주면 표시 모델도 없다`() {
assertNull(credentialsOf(detail(credentials = null)))
}

@Test
fun `아이디와 비밀번호가 모두 비면 표시 모델을 만들지 않는다`() {
val bothMissing = ReceivedAccountCredentials(id = null, password = null)

assertNull(credentialsOf(detail(bothMissing)))
}

@Test
fun `공백뿐인 값은 미제공과 같게 다룬다`() {
val blank = ReceivedAccountCredentials(id = "", password = " ")

assertNull(credentialsOf(detail(blank)))
}

@Test
fun `한쪽만 남겼으면 그쪽만 값으로 싣는다`() {
val idOnly = credentialsOf(detail(ReceivedAccountCredentials(id = "qwerty123", password = null)))

assertEquals("qwerty123", idOnly?.accountId)
assertNull("비밀번호를 남기지 않았는데 값이 생겼다", idOnly?.password)

val passwordOnly = credentialsOf(detail(ReceivedAccountCredentials(id = " ", password = "qwerty123!")))

assertNull("아이디가 공백뿐인데 값으로 실렸다", passwordOnly?.accountId)
assertEquals("qwerty123!", passwordOnly?.password)
}

@Test
fun `둘 다 남겼으면 그대로 싣는다`() {
val both = credentialsOf(detail(ReceivedAccountCredentials(id = "qwerty123", password = "qwerty123!")))

assertEquals("qwerty123", both?.accountId)
assertEquals("qwerty123!", both?.password)
}
}
Loading