diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts
index 93eb814b68..243d5abe2e 100644
--- a/feature/mindrecord/presentation/build.gradle.kts
+++ b/feature/mindrecord/presentation/build.gradle.kts
@@ -24,6 +24,8 @@ dependencies {
implementation(projects.core.ui)
implementation(projects.core.model)
implementation(libs.coil.compose)
+ // 첨부 이미지의 EXIF Orientation 을 읽어 본문 표시 크기를 세운다 (#731 리뷰).
+ implementation(libs.androidx.exifinterface)
// 쓰는 건 BasicRichTextEditor(foundation 계열)뿐인데, richeditor 가 딸려 보내는 Compose
// Multiplatform material3 1.11.0-alpha07 이 androidx material3 를 BOM 판 1.4.0 대신
// 1.5.0-alpha17(alpha)로 끌어올린다(#973). 쓰지 않는 material3 갈래를 끊어 BOM 을 정본으로 되돌린다.
@@ -34,6 +36,8 @@ dependencies {
testImplementation(libs.coroutines.test)
testImplementation(testFixtures(projects.feature.mindrecord.domain))
+ // 첨부 파일명 해석은 ContentResolver 를 타므로 Robolectric 으로 검증한다 (#731).
+ testImplementation(libs.androidx.test.core.ktx)
// 컴파일된 리소스로 문구를 검증한다 — aapt2 의 앞뒤 공백 제거는 소스 XML 만 봐서는 잡히지 않는다 (#732).
// 열린 주차 메뉴의 스크롤·최하단 선택도 JVM 에서 그대로 재현한다 (#729).
// 매퍼가 android.util.Log 를 타 JVM 단위 테스트로는 돌지 않는다 (#751).
diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt
index 1128174f41..ef75e1331c 100644
--- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt
+++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt
@@ -3,7 +3,9 @@ package com.afternote.feature.mindrecord.presentation.component
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.StringRes
import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -17,6 +19,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -25,6 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
@@ -40,6 +44,9 @@ import com.afternote.core.ui.theme.AfternoteTheme
import com.afternote.feature.mindrecord.presentation.R
import com.afternote.feature.mindrecord.presentation.model.TextStyleState
import com.afternote.feature.mindrecord.presentation.model.TextStyleType
+import com.afternote.feature.mindrecord.presentation.util.mediaDisplayName
+import com.afternote.feature.mindrecord.presentation.util.mediaImageSize
+import com.afternote.feature.mindrecord.presentation.util.toUploadedFileKey
import com.mohamedrejeb.richeditor.model.rememberRichTextState
import com.mohamedrejeb.richeditor.ui.BasicRichTextEditor
import kotlinx.coroutines.launch
@@ -61,12 +68,27 @@ fun WriteTextField(
onDraftCountClick: () -> Unit = {},
draftCount: Int? = null,
/**
- * 갤러리에서 고른 이미지를 서버에 업로드하고 영구 URL 을 반환하는 업로더
- * (`POST /files/presigned-url` → S3 PUT). null 이면 업로드 없이 로컬 URI 를 그대로 삽입한다.
- * 업로드 실패(반환값 null) 시 이미지는 본문에 삽입되지 않는다.
+ * 갤러리에서 고른 이미지를 서버에 업로드하고 URL 을 반환하는 업로더
+ * (`POST /files/presigned-url` → S3 PUT). 업로드 실패(반환값 null) 시 본문에 삽입하지 않는다.
+ *
+ * **null 을 넘기지 않는다.** 업로더가 없으면 로컬 `content://` URI 가 본문과 저장 데이터에
+ * 그대로 들어가는데, 그 주소는 다른 기기·수신자에게 아무 의미가 없다 (#731).
*/
onImagePicked: (suspend (uriString: String) -> String?)? = null,
+ /**
+ * 음성·파일 첨부용 업로더. [onImagePicked] 와 같은 경로를 쓰지만 삽입 형태가 다르다 —
+ * 이미지는 `
`, 나머지는 파일명을 텍스트로 하는 `` 다 (#731).
+ */
+ onMediaPicked: (suspend (uriString: String) -> String?)? = null,
) {
+ val context = LocalContext.current
+ // 업로드 실패를 조용히 삼키지 않는다 — 종전에는 실패하면 아무 일도 일어나지 않았다 (#731).
+ // 문자열이 아니라 리소스 ID 로 든다. LocalContext.current.getString 은 Compose 가
+ // 리소스 변경(로케일·설정)에 재구성으로 반응하지 못하는 경로라 lint 가 막는다 (#731 리뷰).
+ var mediaError by remember { mutableStateOf(null) }
+ // 이번 작성에서 붙인 첨부. 에디터가 `
` 를 그리지 못해 사진이 대체 문자로만 보이므로,
+ // 무엇을 붙였는지 여기서 이름으로 확인한다 (#731).
+ val attachments = remember { mutableStateListOf() }
val state = rememberRichTextState()
val scope = rememberCoroutineScope()
@@ -110,42 +132,66 @@ fun WriteTextField(
runCatching { editorFocusRequester.requestFocus() }
}
- // 선택된 미디어 URI 를 HTML 태그로 감싸 에디터에 append. compose-richeditor 의 setHtml 이
- //
· 같은 표준 태그를 파싱해 rich span 으로 변환한다.
- // 음성/파일은 업로드 미지원 (raw content:// URI 를 그대로 href 로 사용).
- fun appendMediaToEditor(
+ /**
+ * 고른 미디어를 업로드한 뒤에만 본문에 넣는다.
+ *
+ * 업로더가 없거나 업로드가 실패하면 **아무것도 넣지 않고** 사유를 남긴다. 종전에는
+ * 로컬 `content://` URI 를 그대로 `href` 와 링크 텍스트로 써서, 다른 기기에서 해석할 수
+ * 없는 주소가 사용자 본문과 저장 데이터에 남았다 (#731).
+ *
+ * 이미지는 `
`, 음성·파일은 **파일명을 텍스트로 하는** `` 로 넣는다. 종전에는
+ * 링크 텍스트까지 `content://…` 라 무엇을 첨부했는지 알아볼 수 없었다.
+ */
+ fun attachMedia(
uri: Uri?,
asImage: Boolean,
) {
if (uri == null) return
- val html =
- if (asImage) "
" else "$uri"
- keepEditorFocus { state.setHtml(state.toHtml() + html) }
+ val uploader = if (asImage) onImagePicked else onMediaPicked
+ if (uploader == null) {
+ mediaError = MediaErrorMessage(R.string.mindrecord_write_media_upload_unavailable)
+ return
+ }
+ val displayName = context.mediaDisplayName(uri)
+ scope.launch {
+ mediaError = null
+ val uploadedUrl = uploader(uri.toString())
+ if (uploadedUrl == null) {
+ mediaError = MediaErrorMessage(R.string.mindrecord_write_media_upload_failed, displayName)
+ return@launch
+ }
+ // 본문에 넣는 값은 업로드 URL 이 아니라 **fileKey** 다. 서버가 본문의 미디어
+ // 참조를 훑어 staging → permanent 로 옮기고 전체 URL 로 재작성하는데, 전체 URL 을
+ // 넣으면 그 앞에 호스트를 한 번 더 붙여 접근 불가한 주소가 저장된다 (#549·#731).
+ // `img src` 와 `a href` 에 같은 규칙이 적용되는 것을 실서버로 확인했다.
+ val fileKey = uploadedUrl.toUploadedFileKey()
+ val html =
+ if (asImage) {
+ // 크기를 비워 두면 직렬화 때 width="0" height="0" 이 붙어 어디서도 보이지
+ // 않는다. 다만 고정값을 박으면 세로 사진이 본문에 4:3 으로 박제되므로
+ // 원본 비율로 높이를 계산한다 (#731 리뷰).
+ val (imageWidth, imageHeight) = context.mediaImageSize(uri, MEDIA_IMAGE_WIDTH_PX)
+ "
"
+ } else {
+ "$displayName"
+ }
+ keepEditorFocus { state.setHtml(state.toHtml() + html) }
+ attachments += displayName
+ }
}
val imageLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
- if (uri == null) return@rememberLauncherForActivityResult
- val uploader = onImagePicked
- if (uploader == null) {
- appendMediaToEditor(uri, asImage = true)
- } else {
- // 업로드 완료 후 영구 URL 로 삽입 — 로컬 URI 는 다른 기기/수신자에게 렌더되지 않는다.
- scope.launch {
- val uploadedUrl = uploader(uri.toString())
- if (uploadedUrl != null) {
- keepEditorFocus { state.setHtml(state.toHtml() + "
") }
- }
- }
- }
+ attachMedia(uri, asImage = true)
}
val voiceLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
- appendMediaToEditor(uri, asImage = false)
+ attachMedia(uri, asImage = false)
}
val fileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
- appendMediaToEditor(uri, asImage = false)
+ attachMedia(uri, asImage = false)
}
Column(modifier = modifier.fillMaxSize()) {
@@ -165,6 +211,25 @@ fun WriteTextField(
.focusRequester(editorFocusRequester)
.padding(16.dp),
)
+ // 첨부 목록과 오류 문구는 같은 자리를 두고 다투면 안 된다 — 종전에는 정렬·패딩이
+ // 같아 둘 다 표시되는 순간 정확히 겹쳐 그려졌다. 한 번이라도 첨부에 성공한 뒤의
+ // 실패는 전부 그 상태였다 (#731 리뷰).
+ if (attachments.isNotEmpty() || mediaError != null) {
+ Column(
+ modifier = Modifier.align(Alignment.BottomStart).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ if (attachments.isNotEmpty()) {
+ AttachmentSummary(names = attachments)
+ }
+ mediaError?.let { message ->
+ Text(
+ text = message.resolve(),
+ color = AfternoteDesign.colors.gray6,
+ )
+ }
+ }
+ }
if (state.annotatedString.text.isEmpty()) {
Text(
text = stringResource(R.string.mindrecord_write_field_placeholder),
@@ -290,3 +355,40 @@ private fun WriteTextFieldPreview() {
WriteTextField()
}
}
+
+/** 에디터 본문 이미지의 가로 기준 크기(px). 높이는 원본 비율로 계산한다 (#731). */
+private const val MEDIA_IMAGE_WIDTH_PX = 320
+
+/**
+ * 이번 작성에서 붙인 첨부 목록.
+ *
+ * compose-richeditor 1.0.0 의 HTML 파서는 `
` 를 다루지 않아, 본문에 사진을 넣어도
+ * 에디터에는 자리 문자만 보인다 (AAR 어느 클래스에도 `img` 상수가 없다). 저장되는
+ * 본문에는 정상적으로 들어가지만 작성 중에는 확인할 방법이 없어, 무엇을 붙였는지
+ * 이름으로라도 보여 준다 (#731).
+ */
+@Composable
+private fun AttachmentSummary(
+ names: List,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = stringResource(R.string.mindrecord_write_media_attached, names.joinToString(", ")),
+ color = AfternoteDesign.colors.gray6,
+ modifier = modifier,
+ )
+}
+
+/**
+ * 첨부 실패 안내 — **표시 시점에** 문자열로 푼다.
+ *
+ * `Context.getString` 을 상태에 담으면 로케일·설정이 바뀌어도 문구가 따라가지 않고,
+ * Compose lint(`LocalContextGetResourceValueCall`)도 그 경로를 막는다 (#731 리뷰).
+ */
+private data class MediaErrorMessage(
+ @param:StringRes val resId: Int,
+ val formatArg: String? = null,
+) {
+ @Composable
+ fun resolve(): String = formatArg?.let { stringResource(resId, it) } ?: stringResource(resId)
+}
diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt
index df4f1e59d8..e8fe4c37fd 100644
--- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt
+++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt
@@ -163,7 +163,8 @@ fun DailyQuestionWriteScreen(
onSaveDraftClick = { viewModel.submit(isDraft = true) },
onDraftCountClick = onDraftListClick,
draftCount = uiState.draftCount,
- onImagePicked = viewModel::uploadImage,
+ onImagePicked = viewModel::uploadMedia,
+ onMediaPicked = viewModel::uploadMedia,
)
}
}
diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt
index d527d247bf..8368c2f3ce 100644
--- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt
+++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt
@@ -298,7 +298,8 @@ fun DiaryWriteScreen(
onSaveDraftClick = { viewModel.submit(isDraft = true) },
onDraftCountClick = onDraftListClick,
draftCount = uiState.draftCount,
- onImagePicked = viewModel::uploadImage,
+ onImagePicked = viewModel::uploadMedia,
+ onMediaPicked = viewModel::uploadMedia,
)
}
diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachment.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachment.kt
new file mode 100644
index 0000000000..ef4ba4f5ce
--- /dev/null
+++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachment.kt
@@ -0,0 +1,108 @@
+package com.afternote.feature.mindrecord.presentation.util
+
+import android.content.Context
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.provider.OpenableColumns
+import androidx.annotation.StringRes
+import androidx.exifinterface.media.ExifInterface
+import com.afternote.feature.mindrecord.presentation.R
+
+/**
+ * 첨부 파일을 사람이 알아볼 이름으로 바꾼다 (`녹음.mp3`, `계약서.pdf`).
+ *
+ * 종전에는 링크 텍스트로 `content://com.android.providers.media.documents/document/audio%3A…`
+ * 를 그대로 썼다. 다른 기기에서 해석되지 않는 로컬 주소인 데다, 무엇을 첨부했는지도
+ * 알아볼 수 없었다 (#731).
+ *
+ * 이름을 못 얻으면 마지막 경로 조각으로, 그것도 없으면 [fallback] 으로 떨어진다 —
+ * 어느 경우에도 `content://` 를 본문에 노출하지 않는다.
+ */
+fun Context.mediaDisplayName(
+ uri: Uri,
+ @StringRes fallbackResId: Int = R.string.mindrecord_write_media_default_name,
+): String {
+ val fromProvider =
+ runCatching {
+ contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
+ val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null
+ }
+ }.getOrNull()
+
+ return fromProvider?.takeIf { it.isNotBlank() }
+ // lastPathSegment 는 이미 '/' 로 자른 마지막 조각이라 다시 자를 것이 없다.
+ ?: uri.lastPathSegment?.takeIf { it.isNotBlank() && !it.contains(':') }
+ ?: getString(fallbackResId)
+}
+
+/**
+ * 본문에 넣을 이미지 표시 크기 — **원본 비율을 지킨다** (#731 리뷰).
+ *
+ * 크기를 비워 두면 직렬화 때 `width="0"` 이 붙어 어디서도 보이지 않아 값을 넣어야 하는데,
+ * 320×240 처럼 박으면 세로 사진이 저장된 본문에 4:3 으로 박제된다 — 이 값을 존중하는
+ * 뷰어(수신자 화면·웹)에서 찌그러지고, 이미 저장된 기록은 나중에 고치기도 어렵다.
+ *
+ * 원본 크기를 못 읽으면 가로 기준값만 쓰고 높이는 비례로 두지 않는다 — 알 수 없는 비율을
+ * 지어내지 않기 위해 정사각으로 떨어뜨린다.
+ */
+fun Context.mediaImageSize(
+ uri: Uri,
+ targetWidthPx: Int,
+): Pair {
+ val bounds =
+ runCatching {
+ contentResolver.openInputStream(uri)?.use { stream ->
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeStream(stream, null, options)
+ options
+ }
+ }.getOrNull()
+
+ val fileWidth = bounds?.outWidth ?: 0
+ val fileHeight = bounds?.outHeight ?: 0
+ if (fileWidth <= 0 || fileHeight <= 0) return targetWidthPx to targetWidthPx
+
+ // 파일 픽셀과 «보이는» 크기가 다를 수 있다 — 아래 참조.
+ val (width, height) =
+ if (isUriRotatedQuarterTurn(uri)) fileHeight to fileWidth else fileWidth to fileHeight
+
+ return targetWidthPx to (targetWidthPx.toLong() * height / width).toInt().coerceAtLeast(1)
+}
+
+/**
+ * 이 이미지를 뷰어가 **90도 돌려서** 보는가 (= 폭과 높이가 뒤바뀌는가).
+ *
+ * `BitmapFactory` 의 `outWidth`/`outHeight` 는 파일에 적힌 픽셀 크기라 EXIF `Orientation` 을
+ * 반영하지 않는다. 요즘 폰은 세로로 찍어도 센서 방향(가로)으로 저장하고 «돌려서 보라» 를
+ * EXIF 에 남기는데, 갤러리·웹뷰어는 그걸 존중하고 `BitmapFactory` 는 무시한다.
+ *
+ * 그래서 이걸 빼면 «400x300 파일 + Orientation=6» 이 320×240 으로 계산돼 **종전 상수와 같은
+ * 값**이 된다 — 비율 계산으로 바꾼 의미가 카메라 세로 촬영본에서만 통째로 사라진다. 첨부
+ * 입력에서 가장 흔한 쪽이라 여기서 잡는다 (#731 리뷰).
+ *
+ * 회전을 못 읽으면 «안 돌아간 것» 으로 본다 — 파일 픽셀 그대로 쓰는 종전 동작이다.
+ * (`ImageDecoder` 는 헤더 크기에 EXIF 를 적용해 주지만 API 28+ 라 minSdk 26 에서는 못 쓴다.)
+ */
+private fun Context.isUriRotatedQuarterTurn(uri: Uri): Boolean {
+ val orientation =
+ runCatching {
+ contentResolver.openInputStream(uri)?.use { stream ->
+ ExifInterface(stream).getAttributeInt(
+ ExifInterface.TAG_ORIENTATION,
+ ExifInterface.ORIENTATION_NORMAL,
+ )
+ }
+ }.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL
+
+ return orientation in QUARTER_TURN_ORIENTATIONS
+}
+
+/** 폭과 높이가 뒤바뀌는 방향들. 180도 회전·좌우 반전은 비율이 그대로라 여기 없다. */
+private val QUARTER_TURN_ORIENTATIONS =
+ setOf(
+ ExifInterface.ORIENTATION_ROTATE_90,
+ ExifInterface.ORIENTATION_ROTATE_270,
+ ExifInterface.ORIENTATION_TRANSPOSE,
+ ExifInterface.ORIENTATION_TRANSVERSE,
+ )
diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt
index 632c12b372..495c97c1b9 100644
--- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt
+++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt
@@ -184,7 +184,7 @@ class DailyQuestionWriteViewModel
}
/**
- * 에디터에서 고른 이미지를 업로드하고 **미리보기에 쓸 URL** 을 반환한다 (실패 시 null).
+ * 에디터에서 고른 **미디어**(사진·음성·파일)를 업로드하고 **미리보기에 쓸 URL** 을 반환한다 (실패 시 null).
*
* 반환한 URL 은 에디터가 본문 HTML 에 `
` 로 삽입한다 — 그것이 서버에
* 이미지가 남는 유일한 경로다. 종전에는 이 URL 을 등록 payload 의 `imageUrl` 로도
@@ -194,7 +194,7 @@ class DailyQuestionWriteViewModel
* 를 기대하므로, 여기서 받은 URL 을 [uploadedImageUrls] 에 기억해 뒀다가 제출 직전에
* 키 형태로 바꾼다 ([toWireContent]). 미리보기는 전체 URL 이라야 뜬다.
*/
- suspend fun uploadImage(uriString: String): String? {
+ suspend fun uploadMedia(uriString: String): String? {
// 실패 문구의 수명은 «다음 업로드 시작까지» 다 — 화면에 걷는 수단이 따로 없고,
// 걷는 함수만 두면 호출부 0건인 죽은 코드가 된다 (#1019 리뷰 지적).
_uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) }
diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt
index aff6035c7d..221b0dfb67 100644
--- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt
+++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt
@@ -98,14 +98,19 @@ class DiaryWriteViewModel
private val uploadedImageUrls = mutableSetOf()
/**
- * 에디터에서 고른 이미지를 presigned URL 로 업로드하고 **미리보기에 쓸 URL** 을 반환한다
- * (실패 시 null). 첫 업로드 이미지는 등록 payload 의 `imageUrl` (목록 카드 썸네일) 로도 쓴다.
+ * 에디터에서 고른 **미디어**(사진·음성·파일)를 presigned URL 로 업로드하고 **미리보기에
+ * 쓸 URL** 을 반환한다 (실패 시 null).
*
* 저장 시 본문에 나가는 값은 이 URL 이 아니다 — 서버는 `img src` 에서 fileKey 를 기대하고
* 전체 URL 을 받으면 호스트를 한 번 더 붙여 403 이 된다. 그래서 받은 URL 을 기억해 두고
* 제출 직전에 키로 바꾼다 ([toWireContent]). 미리보기는 전체 URL 이라야 뜬다 (#1016).
+ *
+ * **첫 업로드 결과는 등록 payload 의 `imageUrl` 로도 실린다** (아래 `it.imageUrl == null`).
+ * 이 PR 이 첨부 대상을 이미지에서 미디어 전체로 넓히면서, 그 자리가 처음으로 음성·파일을
+ * 집을 수 있게 됐다 — 지금은 서버가 그 필드를 무시하고 응답 `imageUrl` 이 null 이라
+ * 사용자 영향이 없지만, 배선 자체는 남아 있다. 후속: #1195.
*/
- suspend fun uploadImage(uriString: String): String? {
+ suspend fun uploadMedia(uriString: String): String? {
// 실패 문구의 수명은 «다음 업로드 시작까지» 다 — 화면에 걷는 수단이 따로 없고,
// 걷는 함수만 두면 호출부 0건인 죽은 코드가 된다 (#1019 리뷰 지적).
_uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) }
diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml
index 9a28374460..f4392abd17 100644
--- a/feature/mindrecord/presentation/src/main/res/values/strings.xml
+++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml
@@ -196,6 +196,12 @@
답변을 입력해 주세요.
오늘의 질문을 불러오지 못해 저장할 수 없습니다. 잠시 후 다시 시도해 주세요.
주간 리포트를 불러오지 못했습니다.
+
+ 지금은 첨부를 올릴 수 없어요. 잠시 뒤 다시 시도해 주세요.
+ 첨부파일
+ %1$s 올리기에 실패했어요. 다시 시도해 주세요.
+ 첨부: %1$s
+
%1$d월 %2$d주차 리포트를 불러오지 못했어요.
다시 시도
diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachmentTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachmentTest.kt
new file mode 100644
index 0000000000..e894350d5e
--- /dev/null
+++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachmentTest.kt
@@ -0,0 +1,154 @@
+package com.afternote.feature.mindrecord.presentation.util
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.net.Uri
+import androidx.exifinterface.media.ExifInterface
+import androidx.test.core.app.ApplicationProvider
+import com.afternote.feature.mindrecord.presentation.R
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+import java.io.FileOutputStream
+
+/**
+ * 첨부 파일명 표시 규칙 (#731).
+ *
+ * 종전에는 링크 텍스트가 `content://com.android.providers.media.documents/document/audio%3A…`
+ * 였다. 다른 기기에서 해석되지 않는 로컬 주소인 데다 무엇을 첨부했는지도 알 수 없었다.
+ * 어떤 경로로 떨어지든 `content://` 가 본문에 노출되지 않는 것을 고정한다.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35])
+class MediaAttachmentTest {
+ private val context: Context = ApplicationProvider.getApplicationContext()
+
+ @Test
+ fun `이름을 못 얻으면 마지막 경로 조각을 쓴다`() {
+ val uri = Uri.parse("content://com.android.providers.downloads.documents/document/recording.mp3")
+
+ assertEquals("recording.mp3", context.mediaDisplayName(uri))
+ }
+
+ @Test
+ fun `문서 제공자의 콜론 표기는 파일명으로 쓰지 않는다`() {
+ // `document/audio%3A1000000123` 처럼 콜론이 든 조각은 사람이 읽을 이름이 아니다.
+ val uri = Uri.parse("content://com.android.providers.media.documents/document/audio%3A1000000123")
+
+ assertEquals("첨부파일", context.mediaDisplayName(uri))
+ }
+
+ @Test
+ fun `어떤 경로로 떨어져도 content 스킴이 본문에 노출되지 않는다`() {
+ val uris =
+ listOf(
+ "content://com.android.providers.media.documents/document/audio%3A1",
+ "content://media/external/audio/media/42",
+ "content://com.android.providers.downloads.documents/document/msf%3A88",
+ )
+
+ uris.forEach { raw ->
+ val name = context.mediaDisplayName(Uri.parse(raw))
+ assertFalse("파일명에 로컬 URI 가 새면 안 된다: $name", name.contains("content://"))
+ }
+ }
+
+ @Test
+ fun `대체 이름을 문자열 리소스로 지정할 수 있다`() {
+ val uri = Uri.parse("content://com.android.providers.media.documents/document/audio%3A1")
+
+ assertEquals(
+ context.getString(R.string.mindrecord_write_media_default_name),
+ context.mediaDisplayName(uri, R.string.mindrecord_write_media_default_name),
+ )
+ }
+
+ /**
+ * 본문 이미지 크기는 **원본 비율을 지킨다** (#731 리뷰).
+ *
+ * 종전에는 320x240 이 상수로 박혀 있어 세로 사진도 저장된 본문에 4:3 으로 들어갔고,
+ * 이 값을 존중하는 뷰어(수신자 화면·웹)에서 찌그러져 보였다.
+ */
+ @Test
+ fun `세로 사진은 세로 비율 그대로 계산한다`() {
+ val uri = pngUri(width = 100, height = 200)
+
+ assertEquals(320 to 640, context.mediaImageSize(uri, targetWidthPx = 320))
+ }
+
+ @Test
+ fun `가로 사진은 가로 비율 그대로 계산한다`() {
+ val uri = pngUri(width = 400, height = 100)
+
+ assertEquals(320 to 80, context.mediaImageSize(uri, targetWidthPx = 320))
+ }
+
+ @Test
+ fun `원본 크기를 못 읽으면 비율을 지어내지 않는다`() {
+ val notAnImage = File.createTempFile("attachment", ".bin").apply { writeText("not an image") }
+
+ assertEquals(320 to 320, context.mediaImageSize(Uri.fromFile(notAnImage), targetWidthPx = 320))
+ }
+
+ /**
+ * EXIF 회전은 파일 픽셀과 «보이는» 크기를 갈라 놓는다 (#731 리뷰).
+ *
+ * 요즘 폰은 세로로 찍어도 센서 방향(가로)으로 저장하고 «90도 돌려서 보라» 를 EXIF 에
+ * 남긴다. `BitmapFactory` 는 그걸 무시하므로, 회전을 안 읽으면 400x300 파일이 320x240 —
+ * **걷어내려던 상수와 같은 값** — 으로 계산된다. 첨부에서 가장 흔한 입력이 카메라 세로
+ * 촬영본이라 여기서 고정한다.
+ */
+ @Test
+ fun `EXIF 90도가 걸린 사진은 돌린 뒤 비율로 계산한다`() {
+ val uri = jpegUri(width = 400, height = 300, orientation = ExifInterface.ORIENTATION_ROTATE_90)
+
+ // 보이는 비율은 300:400 이다 — 320 폭이면 427.
+ assertEquals(320 to 426, context.mediaImageSize(uri, targetWidthPx = 320))
+ }
+
+ @Test
+ fun `EXIF 180도는 비율이 그대로다`() {
+ // 폭과 높이가 뒤바뀌지 않는 방향까지 맞바꾸면 이번엔 반대로 틀린다.
+ val uri = jpegUri(width = 400, height = 300, orientation = ExifInterface.ORIENTATION_ROTATE_180)
+
+ assertEquals(320 to 240, context.mediaImageSize(uri, targetWidthPx = 320))
+ }
+
+ @Test
+ fun `EXIF 가 없는 사진은 파일 픽셀 그대로 쓴다`() {
+ val uri = jpegUri(width = 400, height = 300, orientation = null)
+
+ assertEquals(320 to 240, context.mediaImageSize(uri, targetWidthPx = 320))
+ }
+
+ private fun jpegUri(
+ width: Int,
+ height: Int,
+ orientation: Int?,
+ ): Uri {
+ val file = File.createTempFile("attachment", ".jpg")
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) }
+ if (orientation != null) {
+ ExifInterface(file.absolutePath).apply {
+ setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
+ saveAttributes()
+ }
+ }
+ return Uri.fromFile(file)
+ }
+
+ private fun pngUri(
+ width: Int,
+ height: Int,
+ ): Uri {
+ val file = File.createTempFile("attachment", ".png")
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
+ return Uri.fromFile(file)
+ }
+}
diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt
index fec5dda4d3..c93eeff28f 100644
--- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt
+++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt
@@ -115,7 +115,7 @@ class DailyQuestionWriteViewModelTest {
)
viewModel.onAnswerChanged("사용자가 방금 입력한 답변")
- runBlocking { viewModel.uploadImage("content://just-picked") }
+ runBlocking { viewModel.uploadMedia("content://just-picked") }
viewModel.submit()
// 업로드 URL 은 에디터가 본문에
로 넣는다 — payload 필드로는 나가지 않는다 (#549).
@@ -139,7 +139,7 @@ class DailyQuestionWriteViewModelTest {
noopDraftLoader(),
)
- val url = runBlocking { viewModel.uploadImage("content://picked") }
+ val url = runBlocking { viewModel.uploadMedia("content://picked") }
assertEquals("https://cdn/picked.jpg", url)
}
@@ -231,7 +231,7 @@ class DailyQuestionWriteViewModelTest {
noopDraftLoader(),
)
- val previewUrl = runBlocking { viewModel.uploadImage("content://picked") }
+ val previewUrl = runBlocking { viewModel.uploadMedia("content://picked") }
viewModel.onAnswerChanged("본문
")
viewModel.submit()
diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt
index bd54ec1867..1d86a90fda 100644
--- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt
+++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt
@@ -46,7 +46,7 @@ class DiaryWriteImageKeyTest {
var sent: String? = null
val viewModel = viewModel(onCreate = { sent = it.content })
- val previewUrl = runBlocking { viewModel.uploadImage("content://picked") }
+ val previewUrl = runBlocking { viewModel.uploadMedia("content://picked") }
viewModel.onTitleChanged("제목")
viewModel.onContentChanged("본문
")
viewModel.onMoodSelected(TodayMood.HAPPY)
diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt
index f6a70c5aa4..ea6e4cf9cf 100644
--- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt
+++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt
@@ -123,7 +123,7 @@ class MindRecordFailureRecoveryTest {
)
advanceUntilIdle()
- val uploading = launch { viewModel.uploadImage("content://picked.jpg") }
+ val uploading = launch { viewModel.uploadMedia("content://picked.jpg") }
advanceUntilIdle()
viewModel.onAnswerChanged("답변
")
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 32e928dc95..94f92efc0c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,7 @@
[versions]
agp = "9.2.1"
biometric = "1.1.0"
+exifinterface = "1.4.1"
coreKtx = "1.19.0"
fragmentKtx = "1.8.9"
junit = "4.13.2"
@@ -58,6 +59,7 @@ logback = "1.5.34"
[libraries]
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" }
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }