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) + "\"$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()) { @@ -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" }