Repository navigation
fix(mindrecord): 미디어 첨부를 업로드 뒤 파일명으로 넣는다 — 로컬 content:// URI 노출 제거 #958
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8aebeb5
6a7ffea
ab8a557
1efb9d6
fcaaecf
30cf620
dbcfb23
a923059
cb69b83
26c76b3
8ca5331
d04258c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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] 와 같은 경로를 쓰지만 삽입 형태가 다르다 — | ||
| * 이미지는 `<img>`, 나머지는 파일명을 텍스트로 하는 `<a href>` 다 (#731). | ||
| */ | ||
| onMediaPicked: (suspend (uriString: String) -> String?)? = null, | ||
| ) { | ||
| val context = LocalContext.current | ||
| // 업로드 실패를 조용히 삼키지 않는다 — 종전에는 실패하면 아무 일도 일어나지 않았다 (#731). | ||
| // 문자열이 아니라 리소스 ID 로 든다. LocalContext.current.getString 은 Compose 가 | ||
| // 리소스 변경(로케일·설정)에 재구성으로 반응하지 못하는 경로라 lint 가 막는다 (#731 리뷰). | ||
| var mediaError by remember { mutableStateOf<MediaErrorMessage?>(null) } | ||
| // 이번 작성에서 붙인 첨부. 에디터가 `<img>` 를 그리지 못해 사진이 대체 문자로만 보이므로, | ||
| // 무엇을 붙였는지 여기서 이름으로 확인한다 (#731). | ||
| val attachments = remember { mutableStateListOf<String>() } | ||
| val state = rememberRichTextState() | ||
| val scope = rememberCoroutineScope() | ||
|
|
||
|
|
@@ -110,42 +132,66 @@ fun WriteTextField( | |
| runCatching { editorFocusRequester.requestFocus() } | ||
| } | ||
|
|
||
| // 선택된 미디어 URI 를 HTML 태그로 감싸 에디터에 append. compose-richeditor 의 setHtml 이 | ||
| // <img>·<a href> 같은 표준 태그를 파싱해 rich span 으로 변환한다. | ||
| // 음성/파일은 업로드 미지원 (raw content:// URI 를 그대로 href 로 사용). | ||
| fun appendMediaToEditor( | ||
| /** | ||
| * 고른 미디어를 업로드한 뒤에만 본문에 넣는다. | ||
| * | ||
| * 업로더가 없거나 업로드가 실패하면 **아무것도 넣지 않고** 사유를 남긴다. 종전에는 | ||
| * 로컬 `content://` URI 를 그대로 `href` 와 링크 텍스트로 써서, 다른 기기에서 해석할 수 | ||
| * 없는 주소가 사용자 본문과 저장 데이터에 남았다 (#731). | ||
| * | ||
| * 이미지는 `<img>`, 음성·파일은 **파일명을 텍스트로 하는** `<a href>` 로 넣는다. 종전에는 | ||
| * 링크 텍스트까지 `content://…` 라 무엇을 첨부했는지 알아볼 수 없었다. | ||
| */ | ||
| fun attachMedia( | ||
| uri: Uri?, | ||
| asImage: Boolean, | ||
| ) { | ||
| if (uri == null) return | ||
| val html = | ||
| if (asImage) "<img src=\"$uri\" />" else "<a href=\"$uri\">$uri</a>" | ||
| 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [normal] (지난 리뷰 3번 — 그대로입니다) 이 줄은 메인 스레드에서
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 승인했습니다 — 이 건은 승인을 막지 않는 [normal] 로 유지합니다. 다만 스레드는 열어 둡니다: |
||
| "<img src=\"$fileKey\" alt=\"$displayName\" width=\"$imageWidth\" " + | ||
| "height=\"$imageHeight\" />" | ||
| } else { | ||
| "<a href=\"$fileKey\">$displayName</a>" | ||
| } | ||
| 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() + "<img src=\"$uploadedUrl\" />") } | ||
| } | ||
| } | ||
| } | ||
| 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 | ||
|
1hyok marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * 이번 작성에서 붙인 첨부 목록. | ||
| * | ||
| * compose-richeditor 1.0.0 의 HTML 파서는 `<img>` 를 다루지 않아, 본문에 사진을 넣어도 | ||
| * 에디터에는 자리 문자만 보인다 (AAR 어느 클래스에도 `img` 상수가 없다). 저장되는 | ||
| * 본문에는 정상적으로 들어가지만 작성 중에는 확인할 방법이 없어, 무엇을 붙였는지 | ||
| * 이름으로라도 보여 준다 (#731). | ||
| */ | ||
| @Composable | ||
| private fun AttachmentSummary( | ||
| names: List<String>, | ||
| 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) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
|
1hyok marked this conversation as resolved.
|
||
| uri: Uri, | ||
| targetWidthPx: Int, | ||
| ): Pair<Int, Int> { | ||
| 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, | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[normal] 이 줄은 메인 스레드에서
ContentResolver를 연다.scope는rememberCoroutineScope()(WriteTextField.kt:93)라 디스패처가Dispatchers.Main이다.mediaImageSize안의openInputStream+decodeStream은inJustDecodeBounds라 픽셀을 디코드하진 않지만, ContentProvider IPC 와 헤더 읽기는 그대로 UI 스레드에서 일어난다. 클라우드 사진처럼 프로바이더가 느린 경우엔 그만큼 프레임이 밀린다.withContext(Dispatchers.IO)로 감싸면 끝난다 — 이미 코루틴 안이라 비용도 없다.바로 위
context.mediaDisplayName(uri)(:148)도 같은 성질인데 그건 이 PR 이전부터 있던 자리이고, 아예launch밖이라 콜백 스레드에서 돈다. 같이 IO 로 옮기면 한 번에 정리된다.