From 8aebeb5a7adf53ad84bb59b2c5afcb085253ab5a Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 22:01:11 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(mindrecord):=20=EB=AF=B8=EB=94=94?= =?UTF-8?q?=EC=96=B4=20=EC=B2=A8=EB=B6=80=EB=A5=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=92=A4=20=ED=8C=8C=EC=9D=BC=EB=AA=85=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=84=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 음성·파일은 업로드 없이 기기 로컬 `content://` URI 를 그대로 `href` 와 링크 텍스트로 썼다. 다른 기기에서 해석되지 않는 주소가 사용자 본문과 저장 데이터에 남고, 무엇을 첨부했는지도 알아볼 수 없었다. 세 경로(이미지·음성·파일)를 한 갈래로 모아 **업로드에 성공한 뒤에만** 본문에 넣는다. 이미지는 ``, 나머지는 파일명을 텍스트로 하는 `` 다. 업로더가 없거나 업로드가 실패하면 아무것도 넣지 않고 사유를 남긴다 — 종전에는 조용히 아무 일도 일어나지 않았다. 본문에 넣는 값은 업로드 URL 이 아니라 fileKey 다. 서버가 미디어 참조를 훑어 staging→permanent 로 옮기고 전체 URL 로 재작성하는데, 전체 URL 을 넣으면 그 앞에 호스트를 한 번 더 붙여 접근 불가한 주소가 저장된다. `img src` 와 `a href` 에 같은 규칙이 적용되는 것을 실서버로 확인했다. 삽입 시점에 키를 쓰므로 데일리질문과 일기가 같은 경로로 맞춰진다. 이미지에 표시 크기를 함께 넣는다. 비워 두면 직렬화 때 `width="0" height="0"` 이 붙어 저장은 되지만 어디서도 보이지 않는다. compose-richeditor 1.0.0 의 HTML 파서는 `` 를 다루지 않아 작성 중에는 사진이 자리 문자로만 보인다. 저장되는 본문에는 정상적으로 들어가므로, 무엇을 붙였는지 이름으로 확인할 수 있게 첨부 목록을 에디터 아래에 보여 준다. Closes #731 --- .../mindrecord/presentation/build.gradle.kts | 6 + .../presentation/component/WriteTextField.kt | 126 ++++++++++++++---- .../screen/sender/DailyQuestionWriteScreen.kt | 1 + .../screen/sender/DiaryWriteScreen.kt | 1 + .../presentation/util/MediaDisplayName.kt | 32 +++++ .../src/main/res/values/strings.xml | 4 + .../presentation/util/MediaDisplayNameTest.kt | 61 +++++++++ 7 files changed, 206 insertions(+), 25 deletions(-) create mode 100644 feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayName.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayNameTest.kt diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 76d0c5e121..df7dac2ab7 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -7,6 +7,8 @@ plugins { android { namespace = "com.afternote.feature.mindrecord.presentation" resourcePrefix = "mindrecord_" + + testOptions.unitTests.isIncludeAndroidResources = true } dependencies { @@ -19,4 +21,8 @@ dependencies { implementation(libs.compose.rich.editor) testImplementation(libs.coroutines.test) + + // 첨부 파일명 해석은 ContentResolver 를 타므로 Robolectric 으로 검증한다 (#731). + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core.ktx) } 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 c7df678447..4e571eb178 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 @@ -17,6 +17,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 +26,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 +42,8 @@ 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.toUploadedFileKey import com.mohamedrejeb.richeditor.model.rememberRichTextState import com.mohamedrejeb.richeditor.ui.BasicRichTextEditor import kotlinx.coroutines.launch @@ -61,12 +65,25 @@ fun WriteTextField( onDraftCountClick: () -> Unit = {}, draftCount: Int = 0, /** - * 갤러리에서 고른 이미지를 서버에 업로드하고 영구 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). + var mediaError by remember { mutableStateOf(null) } + // 이번 작성에서 붙인 첨부. 에디터가 `` 를 그리지 못해 사진이 대체 문자로만 보이므로, + // 무엇을 붙였는지 여기서 이름으로 확인한다 (#731). + val attachments = remember { mutableStateListOf() } val state = rememberRichTextState() val scope = rememberCoroutineScope() @@ -102,42 +119,63 @@ 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 = context.getString(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 = context.getString(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" 이 붙어 어디서도 보이지 않는다. + "\"$displayName\"" + } 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()) { @@ -157,6 +195,19 @@ fun WriteTextField( .focusRequester(editorFocusRequester) .padding(16.dp), ) + if (attachments.isNotEmpty()) { + AttachmentSummary( + names = attachments, + modifier = Modifier.align(Alignment.BottomStart).padding(16.dp), + ) + } + mediaError?.let { message -> + Text( + text = message, + color = AfternoteDesign.colors.gray6, + modifier = Modifier.align(Alignment.BottomStart).padding(16.dp), + ) + } if (state.annotatedString.text.isEmpty()) { Text( text = stringResource(R.string.mindrecord_write_field_placeholder), @@ -282,3 +333,28 @@ private fun WriteTextFieldPreview() { WriteTextField() } } + +/** 에디터 본문 이미지의 기본 표시 크기(px). 비워 두면 직렬화 때 `width="0"` 이 붙는다 (#731). */ +private const val MEDIA_IMAGE_WIDTH_PX = 320 + +private const val MEDIA_IMAGE_HEIGHT_PX = 240 + +/** + * 이번 작성에서 붙인 첨부 목록. + * + * 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, + ) +} 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 8044dc21e5..4c94b0c695 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 @@ -123,6 +123,7 @@ fun DailyQuestionWriteScreen( onSaveDraftClick = { viewModel.submit(isDraft = true) }, onDraftCountClick = onDraftListClick, onImagePicked = viewModel::uploadImage, + onMediaPicked = viewModel::uploadImage, ) } } 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 049058e1b4..d70ce48242 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 @@ -265,6 +265,7 @@ fun DiaryWriteScreen( onSaveDraftClick = { viewModel.submit(isDraft = true) }, onDraftCountClick = onDraftListClick, onImagePicked = viewModel::uploadImage, + onMediaPicked = viewModel::uploadImage, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayName.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayName.kt new file mode 100644 index 0000000000..600b2189fd --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayName.kt @@ -0,0 +1,32 @@ +package com.afternote.feature.mindrecord.presentation.util + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns + +/** + * 첨부 파일을 사람이 알아볼 이름으로 바꾼다 (`녹음.mp3`, `계약서.pdf`). + * + * 종전에는 링크 텍스트로 `content://com.android.providers.media.documents/document/audio%3A…` + * 를 그대로 썼다. 다른 기기에서 해석되지 않는 로컬 주소인 데다, 무엇을 첨부했는지도 + * 알아볼 수 없었다 (#731). + * + * 이름을 못 얻으면 마지막 경로 조각으로, 그것도 없으면 [fallback] 으로 떨어진다 — + * 어느 경우에도 `content://` 를 본문에 노출하지 않는다. + */ +fun Context.mediaDisplayName( + uri: Uri, + fallback: String = "첨부파일", +): 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() } + ?: uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() && !it.contains(':') } + ?: fallback +} diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index 66dce6e8af..a9225b2911 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -166,4 +166,8 @@ 답변을 입력해 주세요. 오늘의 질문을 불러오지 못해 저장할 수 없습니다. 잠시 후 다시 시도해 주세요. 주간 리포트를 불러오지 못했습니다. + + 지금은 첨부를 올릴 수 없어요. 잠시 뒤 다시 시도해 주세요. + %1$s 올리기에 실패했어요. 다시 시도해 주세요. + 첨부: %1$s diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayNameTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayNameTest.kt new file mode 100644 index 0000000000..ed96910a1f --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayNameTest.kt @@ -0,0 +1,61 @@ +package com.afternote.feature.mindrecord.presentation.util + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +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 + +/** + * 첨부 파일명 표시 규칙 (#731). + * + * 종전에는 링크 텍스트가 `content://com.android.providers.media.documents/document/audio%3A…` + * 였다. 다른 기기에서 해석되지 않는 로컬 주소인 데다 무엇을 첨부했는지도 알 수 없었다. + * 어떤 경로로 떨어지든 `content://` 가 본문에 노출되지 않는 것을 고정한다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class MediaDisplayNameTest { + 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.mediaDisplayName(uri, fallback = "음성")) + } +} From ab8a557801a1fb09e53eee3bfd165d5e9d08acdb Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 23:45:33 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(mindrecord):=20=EB=B3=B8=EB=AC=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=81=AC=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=EB=B9=84=EC=9C=A8=EB=A1=9C=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 320x240 을 상수로 박아 두어 세로 사진도 저장된 본문에 4:3 으로 들어갔다. 이 값을 존중하는 뷰어(수신자 화면·웹)에서는 찌그러져 보이고, 한 번 저장된 기록은 나중에 고치기도 어렵다. ContentResolver 로 원본 크기를 읽어 가로 320px 기준 높이를 비례로 계산한다. 크기를 못 읽으면 비율을 지어내지 않고 정사각으로 떨어뜨린다. 덧붙여 첨부 파일명 대체값을 문자열 리소스로 옮기고, lastPathSegment 를 다시 '/' 로 자르던 무의미한 호출을 지웠다. 사진 외 음성·파일도 함께 태우는 경로라 uploadImage 를 uploadMedia 로 고쳤다. Co-Authored-By: Claude Opus 5 (1M context) --- .../presentation/component/WriteTextField.kt | 66 +++++++++++++------ .../screen/sender/DailyQuestionWriteScreen.kt | 4 +- .../screen/sender/DiaryWriteScreen.kt | 4 +- .../presentation/util/MediaDisplayName.kt | 40 ++++++++++- .../viewmodel/DailyQuestionWriteViewModel.kt | 4 +- .../viewmodel/DiaryWriteViewModel.kt | 4 +- .../src/main/res/values/strings.xml | 1 + .../presentation/util/MediaDisplayNameTest.kt | 48 +++++++++++++- .../DailyQuestionWriteViewModelTest.kt | 6 +- 9 files changed, 141 insertions(+), 36 deletions(-) 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 4e571eb178..a7f398e0b1 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 @@ -43,6 +45,7 @@ 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 @@ -80,7 +83,9 @@ fun WriteTextField( ) { val context = LocalContext.current // 업로드 실패를 조용히 삼키지 않는다 — 종전에는 실패하면 아무 일도 일어나지 않았다 (#731). - var mediaError by remember { mutableStateOf(null) } + // 문자열이 아니라 리소스 ID 로 든다. LocalContext.current.getString 은 Compose 가 + // 리소스 변경(로케일·설정)에 재구성으로 반응하지 못하는 경로라 lint 가 막는다 (#731 리뷰). + var mediaError by remember { mutableStateOf(null) } // 이번 작성에서 붙인 첨부. 에디터가 `` 를 그리지 못해 사진이 대체 문자로만 보이므로, // 무엇을 붙였는지 여기서 이름으로 확인한다 (#731). val attachments = remember { mutableStateListOf() } @@ -136,7 +141,7 @@ fun WriteTextField( if (uri == null) return val uploader = if (asImage) onImagePicked else onMediaPicked if (uploader == null) { - mediaError = context.getString(R.string.mindrecord_write_media_upload_unavailable) + mediaError = MediaErrorMessage(R.string.mindrecord_write_media_upload_unavailable) return } val displayName = context.mediaDisplayName(uri) @@ -144,7 +149,7 @@ fun WriteTextField( mediaError = null val uploadedUrl = uploader(uri.toString()) if (uploadedUrl == null) { - mediaError = context.getString(R.string.mindrecord_write_media_upload_failed, displayName) + mediaError = MediaErrorMessage(R.string.mindrecord_write_media_upload_failed, displayName) return@launch } // 본문에 넣는 값은 업로드 URL 이 아니라 **fileKey** 다. 서버가 본문의 미디어 @@ -154,9 +159,12 @@ fun WriteTextField( val fileKey = uploadedUrl.toUploadedFileKey() val html = if (asImage) { - // 크기를 비워 두면 직렬화 때 width="0" height="0" 이 붙어 어디서도 보이지 않는다. - "\"$displayName\"" + // 크기를 비워 두면 직렬화 때 width="0" height="0" 이 붙어 어디서도 보이지 + // 않는다. 다만 고정값을 박으면 세로 사진이 본문에 4:3 으로 박제되므로 + // 원본 비율로 높이를 계산한다 (#731 리뷰). + val (imageWidth, imageHeight) = context.mediaImageSize(uri, MEDIA_IMAGE_WIDTH_PX) + "\"$displayName\"" } else { "$displayName" } @@ -195,18 +203,24 @@ fun WriteTextField( .focusRequester(editorFocusRequester) .padding(16.dp), ) - if (attachments.isNotEmpty()) { - AttachmentSummary( - names = attachments, + // 첨부 목록과 오류 문구는 같은 자리를 두고 다투면 안 된다 — 종전에는 정렬·패딩이 + // 같아 둘 다 표시되는 순간 정확히 겹쳐 그려졌다. 한 번이라도 첨부에 성공한 뒤의 + // 실패는 전부 그 상태였다 (#731 리뷰). + if (attachments.isNotEmpty() || mediaError != null) { + Column( modifier = Modifier.align(Alignment.BottomStart).padding(16.dp), - ) - } - mediaError?.let { message -> - Text( - text = message, - color = AfternoteDesign.colors.gray6, - 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( @@ -334,11 +348,9 @@ private fun WriteTextFieldPreview() { } } -/** 에디터 본문 이미지의 기본 표시 크기(px). 비워 두면 직렬화 때 `width="0"` 이 붙는다 (#731). */ +/** 에디터 본문 이미지의 가로 기준 크기(px). 높이는 원본 비율로 계산한다 (#731). */ private const val MEDIA_IMAGE_WIDTH_PX = 320 -private const val MEDIA_IMAGE_HEIGHT_PX = 240 - /** * 이번 작성에서 붙인 첨부 목록. * @@ -358,3 +370,17 @@ private fun AttachmentSummary( 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 4c94b0c695..17f155e3a2 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 @@ -122,8 +122,8 @@ fun DailyQuestionWriteScreen( onValueChange = viewModel::onAnswerChanged, onSaveDraftClick = { viewModel.submit(isDraft = true) }, onDraftCountClick = onDraftListClick, - onImagePicked = viewModel::uploadImage, - onMediaPicked = 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 d70ce48242..aa66f1130f 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 @@ -264,8 +264,8 @@ fun DiaryWriteScreen( onValueChange = viewModel::onContentChanged, onSaveDraftClick = { viewModel.submit(isDraft = true) }, onDraftCountClick = onDraftListClick, - onImagePicked = viewModel::uploadImage, - onMediaPicked = viewModel::uploadImage, + onImagePicked = viewModel::uploadMedia, + onMediaPicked = viewModel::uploadMedia, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayName.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayName.kt index 600b2189fd..42ab36023a 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayName.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayName.kt @@ -1,8 +1,11 @@ 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 com.afternote.feature.mindrecord.presentation.R /** * 첨부 파일을 사람이 알아볼 이름으로 바꾼다 (`녹음.mp3`, `계약서.pdf`). @@ -16,7 +19,7 @@ import android.provider.OpenableColumns */ fun Context.mediaDisplayName( uri: Uri, - fallback: String = "첨부파일", + @StringRes fallbackResId: Int = R.string.mindrecord_write_media_default_name, ): String { val fromProvider = runCatching { @@ -27,6 +30,37 @@ fun Context.mediaDisplayName( }.getOrNull() return fromProvider?.takeIf { it.isNotBlank() } - ?: uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() && !it.contains(':') } - ?: fallback + // 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 width = bounds?.outWidth ?: 0 + val height = bounds?.outHeight ?: 0 + if (width <= 0 || height <= 0) return targetWidthPx to targetWidthPx + + return targetWidthPx to (targetWidthPx.toLong() * height / width).toInt().coerceAtLeast(1) } 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 b82801f59a..9821b31e51 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 @@ -103,7 +103,7 @@ class DailyQuestionWriteViewModel } /** - * 에디터에서 고른 이미지를 업로드하고 **미리보기에 쓸 URL** 을 반환한다 (실패 시 null). + * 에디터에서 고른 **미디어**(사진·음성·파일)를 업로드하고 **미리보기에 쓸 URL** 을 반환한다 (실패 시 null). * * 반환한 URL 은 에디터가 본문 HTML 에 `` 로 삽입한다 — 그것이 서버에 * 이미지가 남는 유일한 경로다. 종전에는 이 URL 을 등록 payload 의 `imageUrl` 로도 @@ -113,7 +113,7 @@ class DailyQuestionWriteViewModel * 를 기대하므로, 여기서 받은 URL 을 [uploadedImageUrls] 에 기억해 뒀다가 제출 직전에 * 키 형태로 바꾼다 ([toWireContent]). 미리보기는 전체 URL 이라야 뜬다. */ - suspend fun uploadImage(uriString: String): String? = + suspend fun uploadMedia(uriString: String): String? = photoUploadRepository .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) .onSuccess { url -> uploadedImageUrls += url } 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 9743c1e537..a479c07ba4 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 @@ -78,10 +78,10 @@ class DiaryWriteViewModel } /** - * 에디터에서 고른 이미지를 presigned URL 로 업로드하고 영구 URL 을 반환한다 (실패 시 null). + * 에디터에서 고른 **미디어**(사진·음성·파일)를 presigned URL 로 업로드하고 영구 URL 을 반환한다 (실패 시 null). * 첫 업로드 이미지는 등록 payload 의 `imageUrl` (목록 카드 썸네일) 로도 쓴다. */ - suspend fun uploadImage(uriString: String): String? = + suspend fun uploadMedia(uriString: String): String? = photoUploadRepository .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) .onSuccess { url -> diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index a9225b2911..dd232d69ea 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -168,6 +168,7 @@ 주간 리포트를 불러오지 못했습니다. 지금은 첨부를 올릴 수 없어요. 잠시 뒤 다시 시도해 주세요. + 첨부파일 %1$s 올리기에 실패했어요. 다시 시도해 주세요. 첨부: %1$s diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayNameTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayNameTest.kt index ed96910a1f..5469182907 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayNameTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayNameTest.kt @@ -1,14 +1,18 @@ package com.afternote.feature.mindrecord.presentation.util import android.content.Context +import android.graphics.Bitmap import android.net.Uri 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). @@ -53,9 +57,49 @@ class MediaDisplayNameTest { } @Test - fun `대체 이름을 지정할 수 있다`() { + fun `대체 이름을 문자열 리소스로 지정할 수 있다`() { val uri = Uri.parse("content://com.android.providers.media.documents/document/audio%3A1") - assertEquals("음성", context.mediaDisplayName(uri, fallback = "음성")) + 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)) + } + + 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 9f716ce316..fdeb918c71 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 @@ -100,7 +100,7 @@ class DailyQuestionWriteViewModelTest { ) viewModel.onAnswerChanged("사용자가 방금 입력한 답변") - runBlocking { viewModel.uploadImage("content://just-picked") } + runBlocking { viewModel.uploadMedia("content://just-picked") } viewModel.submit() // 업로드 URL 은 에디터가 본문에 로 넣는다 — payload 필드로는 나가지 않는다 (#549). @@ -122,7 +122,7 @@ class DailyQuestionWriteViewModelTest { PhotoUploadRepository { _, _ -> Result.success("https://cdn/picked.jpg") }, ) - val url = runBlocking { viewModel.uploadImage("content://picked") } + val url = runBlocking { viewModel.uploadMedia("content://picked") } assertEquals("https://cdn/picked.jpg", url) } @@ -198,7 +198,7 @@ class DailyQuestionWriteViewModelTest { }, ) - val previewUrl = runBlocking { viewModel.uploadImage("content://picked") } + val previewUrl = runBlocking { viewModel.uploadMedia("content://picked") } viewModel.onAnswerChanged("

본문

") viewModel.submit() From 26c76b3b7de136fd98472030bfd2e661e3e75af7 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Wed, 26 Aug 2026 16:41:54 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(mindrecord):=20=EB=B3=B8=EB=AC=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=81=AC=EA=B8=B0=EA=B0=80=20EXI?= =?UTF-8?q?F=20=ED=9A=8C=EC=A0=84=EC=9D=84=20=EB=B0=98=EC=98=81=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BitmapFactory 의 outWidth/outHeight 는 파일에 적힌 픽셀 크기라 EXIF Orientation 을 반영하지 않는다. 요즘 폰은 세로로 찍어도 센서 방향(가로)으로 저장하고 «돌려서 보라» 를 EXIF 에 남기는데, 갤러리·웹뷰어는 그걸 존중하고 BitmapFactory 는 무시한다. 그래서 비율 계산으로 바꾼 의미가 **카메라 촬영본에서만 통째로 사라졌다** — 첨부 입력에서 가장 흔한 쪽이 종전 상수와 같은 값으로 떨어졌다. 저장된 본문은 나중에 고치기 어렵다. - androidx.exifinterface 를 더하고, TAG_ORIENTATION 이 폭·높이를 맞바꾸는 방향 (ROTATE_90/270 · TRANSPOSE · TRANSVERSE)이면 뒤집어 비율을 계산한다. 180도·좌우 반전은 비율이 그대로라 대상이 아니다 - 회전을 못 읽으면 «안 돌아간 것» 으로 본다 — 파일 픽셀을 쓰는 종전 동작이다 - 파일명이 내용을 설명하도록 MediaDisplayName.kt → MediaAttachment.kt (테스트도 함께). 이 레포는 파일명·심볼명 불일치로 죽은 코드 판정을 한 번 그르친 적이 있다 (#1064) - uploadMedia KDoc 에 «첫 업로드가 payload 의 imageUrl 로도 실린다» 는 사실을 되살렸다. 코드는 남아 있는데 설명만 지워져 있었다 — 후속은 #1195 가드: EXIF 90도 · 180도 · EXIF 없음 세 케이스. 맞바꾸기를 빼면 90도가 실패한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../mindrecord/presentation/build.gradle.kts | 2 + ...MediaDisplayName.kt => MediaAttachment.kt} | 48 +++++++++++++++-- .../viewmodel/DiaryWriteViewModel.kt | 5 ++ ...playNameTest.kt => MediaAttachmentTest.kt} | 51 ++++++++++++++++++- gradle/libs.versions.toml | 2 + 5 files changed, 104 insertions(+), 4 deletions(-) rename feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/{MediaDisplayName.kt => MediaAttachment.kt} (55%) rename feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/{MediaDisplayNameTest.kt => MediaAttachmentTest.kt} (65%) diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index b59b0679ad..bde5bc8905 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -19,6 +19,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 을 정본으로 되돌린다. diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayName.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachment.kt similarity index 55% rename from feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayName.kt rename to feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachment.kt index 42ab36023a..ef4ba4f5ce 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayName.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachment.kt @@ -5,6 +5,7 @@ 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 /** @@ -58,9 +59,50 @@ fun Context.mediaImageSize( } }.getOrNull() - val width = bounds?.outWidth ?: 0 - val height = bounds?.outHeight ?: 0 - if (width <= 0 || height <= 0) return targetWidthPx to targetWidthPx + 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/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index 7ebaabc986..2446d65f74 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 @@ -107,6 +107,11 @@ class DiaryWriteViewModel * 저장 시 본문에 나가는 값은 이 URL 이 아니다 — 서버는 `img src` 에서 fileKey 를 기대하고 * 전체 URL 을 받으면 호스트를 한 번 더 붙여 403 이 된다. 그래서 받은 URL 을 기억해 두고 * 제출 직전에 키로 바꾼다 ([toWireContent]). 미리보기는 전체 URL 이라야 뜬다 (#1016). + * + * **첫 업로드 결과는 등록 payload 의 `imageUrl` 로도 실린다** (아래 `it.imageUrl == null`). + * 이 PR 이 첨부 대상을 이미지에서 미디어 전체로 넓히면서, 그 자리가 처음으로 음성·파일을 + * 집을 수 있게 됐다 — 지금은 서버가 그 필드를 무시하고 응답 `imageUrl` 이 null 이라 + * 사용자 영향이 없지만, 배선 자체는 남아 있다. 후속: #1195. */ suspend fun uploadMedia(uriString: String): String? { _uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayNameTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachmentTest.kt similarity index 65% rename from feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayNameTest.kt rename to feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachmentTest.kt index 5469182907..e894350d5e 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaDisplayNameTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/MediaAttachmentTest.kt @@ -3,6 +3,7 @@ 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 @@ -23,7 +24,7 @@ import java.io.FileOutputStream */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) -class MediaDisplayNameTest { +class MediaAttachmentTest { private val context: Context = ApplicationProvider.getApplicationContext() @Test @@ -93,6 +94,54 @@ class MediaDisplayNameTest { 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, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 29e1e87945..b2f5a757be 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" }