Skip to content

Conversation

@DongJun-H
Copy link
Member

작업 사항

  • 업로드 이미지 편집기능 내재화
  • 대용량 이미지 로드 대응
  • 의도치 않은 이미지 방향 오류 발생 해결

구현 내용

  • Third-party cropper library 제거 및 Compose 기반의 인앱 이미지 편집 기능 구현
  • 대용량 이미지 로드간 OOM 방지 처리
  • EXIF 정보를 활용해 이미지 로드시, 유저의 의도와 달리 회전되어있는 경우 유저 입장에서 정상적으로 보이도록 이미지 처리
  • Write 관련 state에 대해 lifecycle에 맞게 유지되도록 변경

구현 세부사항

Custom Image Cropper

  • 크롭 이미지 영역 지정시 정사각형 형태의 그리드 형태로 구현
  • 크롭 이미지 영역 지정시 두 손가락으로 조작하는 Pinch-Zoom 제스처와 한 손가락으로 사각형의 각 4개의 모서리로 영역을 조작하는 제스처를 통한 영역 지정 제스처 구현
    • 최소 이미지 처리 추가
    • 크롭 진행간 제스처 겹침 및 이미지뷰 외부 영역으로 영역 벗어남 등에 대한 예외 처리 적용
  • 관련 ImageAsset 등의 데이터 클래스 등을 활용해 크롭 상태 관리 처리
  • 이외 지정 영역에 대한 그리드 및 shadow 등 UI 개선

대용량 이미지 로드

  • 업로드 이미지 로드에 대해 이미지 로드이전 이미지 정보를 미리 읽고 inSampleSize를 개선해 기기에 최적화된 디코딩을 통한 OOM 방지 처리
    • inJustDecodeBounds = true/false
  • 글 작성화면 및 이미지 편집화면 진입시 이미지 로드간 비동기적 로드를 통해 OOM 방지처리
  • 업로드 시점에만 서버와 미리 지정한 사이즈로 리사이징 및 파일포맷 변환, 업로드 처리 이후 관련 메모리 해제 처리로직 추가
  • 편집 완료 경우에 대한 이미지 파일 캐싱처리 및 캐시키를 활용해 갱신 처리

참고

@DongJun-H DongJun-H requested review from Copilot and yuni-ju August 29, 2025 11:07
@DongJun-H DongJun-H self-assigned this Aug 29, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements a complete overhaul of the image upload flow by replacing third-party image cropping with a custom in-app solution, adding large image handling capabilities, and fixing image orientation issues through EXIF data processing.

Key changes include:

  • Custom Compose-based image cropping functionality replacing third-party library
  • Large image OOM prevention through optimized bitmap loading and sampling
  • EXIF-based automatic image orientation correction

Reviewed Changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
WriteViewModel.kt Refactored image handling to use ImageAsset data class, added async bitmap loading with OOM prevention, implemented custom cropping logic
WriteScreen.kt Updated UI to use AsyncImage with cache keys, added image edit functionality, removed third-party cropper integration
WriteNavigation.kt Added navigation route for custom crop screen
ImageCropStateHolder.kt New state management class for crop operations with gesture handling
ImageCropScreen.kt New custom image cropping UI with pinch-zoom and corner drag gestures
ImageSamplingUtils.kt New utility for optimized bitmap loading with inSampleSize calculation
ImageOrientationUtil.kt New EXIF data processing utilities for image orientation correction
build.gradle Removed third-party image cropper dependency
AndroidManifest.xml Removed third-party cropper activity declaration
strings.xml Added new string resource for image edit functionality
ic_crop.xml Added new crop icon drawable

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment on lines +150 to +158
val resizedBitmap = resizeBitmap(
bitmap.cropCenterBitmap(),
POST_IMAGE_RESIZE_SIZE,
POST_IMAGE_RESIZE_SIZE
)
val file = resizedBitmap.toFile(uploadImagePath)
bitmap.recycle()
resizedBitmap.recycle()
file
Copy link

Copilot AI Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code doesn't handle the case where BitmapFactory.decodeStream() returns null, which could cause a NullPointerException when calling bitmap.cropCenterBitmap() on line 151. Add a null check for the bitmap before processing.

Suggested change
val resizedBitmap = resizeBitmap(
bitmap.cropCenterBitmap(),
POST_IMAGE_RESIZE_SIZE,
POST_IMAGE_RESIZE_SIZE
)
val file = resizedBitmap.toFile(uploadImagePath)
bitmap.recycle()
resizedBitmap.recycle()
file
if (bitmap == null) {
null
} else {
val resizedBitmap = resizeBitmap(
bitmap.cropCenterBitmap(),
POST_IMAGE_RESIZE_SIZE,
POST_IMAGE_RESIZE_SIZE
)
val file = resizedBitmap.toFile(uploadImagePath)
bitmap.recycle()
resizedBitmap.recycle()
file
}

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +20
fun ContentResolver.readExifInfo(uri: Uri): ExifInfo {
openFileDescriptor(uri, "r")?.use { pfd ->
val exif = ExifInterface(pfd.fileDescriptor)
val o = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
return when (o) {
Copy link

Copilot AI Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If openFileDescriptor() returns null, the function will fall through to return the default ExifInfo at line 31, but this behavior should be more explicit. Consider handling the null case explicitly or documenting this fallback behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +295 to +298
uris.map { uri ->
val exifInfo = applicationContext.contentResolver.readExifInfo(uri)
ImageAsset(uriString = uri.toString(), exifInfo = exifInfo)
}
Copy link

Copilot AI Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Reading EXIF information is an I/O operation that should be performed on a background thread. Since this is already wrapped in withContext(Dispatchers.IO), the current implementation is correct, but consider adding error handling for potential I/O exceptions when reading EXIF data.

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +46
private val _cropProperties = mutableStateOf(CropProperties())
val cropProperties: State<CropProperties> = _cropProperties

private val imageAspectRatio = originalBitmap.height.toFloat() / originalBitmap.width
val imageWidth = containerSize.width.toFloat()
val imageHeight = imageWidth * imageAspectRatio
val imageTop = (containerSize.height - imageHeight) / 2f
private val maxCropSize = min(imageWidth, imageHeight)

init {
val initialSize = min(imageWidth, imageHeight) * 0.8f
_cropProperties.value = CropProperties(
cropSize = initialSize,
cropOffset = Offset(
x = (imageWidth - initialSize) / 2f,
y = imageTop + (imageHeight - initialSize) / 2f
)
)
}
Copy link

Copilot AI Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The _cropProperties is initialized with an empty CropProperties() but then immediately overwritten in the init block. Consider initializing it directly with the computed initial values to avoid the unnecessary intermediate state.

Suggested change
private val _cropProperties = mutableStateOf(CropProperties())
val cropProperties: State<CropProperties> = _cropProperties
private val imageAspectRatio = originalBitmap.height.toFloat() / originalBitmap.width
val imageWidth = containerSize.width.toFloat()
val imageHeight = imageWidth * imageAspectRatio
val imageTop = (containerSize.height - imageHeight) / 2f
private val maxCropSize = min(imageWidth, imageHeight)
init {
val initialSize = min(imageWidth, imageHeight) * 0.8f
_cropProperties.value = CropProperties(
cropSize = initialSize,
cropOffset = Offset(
x = (imageWidth - initialSize) / 2f,
y = imageTop + (imageHeight - initialSize) / 2f
)
)
}
private val imageAspectRatio = originalBitmap.height.toFloat() / originalBitmap.width
val imageWidth = containerSize.width.toFloat()
val imageHeight = imageWidth * imageAspectRatio
val imageTop = (containerSize.height - imageHeight) / 2f
private val maxCropSize = min(imageWidth, imageHeight)
private val _cropProperties = mutableStateOf(
run {
val initialSize = min(imageWidth, imageHeight) * 0.8f
CropProperties(
cropSize = initialSize,
cropOffset = Offset(
x = (imageWidth - initialSize) / 2f,
y = imageTop + (imageHeight - initialSize) / 2f
)
)
}
)
val cropProperties: State<CropProperties> = _cropProperties

Copilot uses AI. Check for mistakes.
Copy link
Member

@yuni-ju yuni-ju left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 이미지 편집 시 핀치 줌으로 편리하게 크롭이 잘 되는 것 너무 좋습니다~!
    • 다만 한 번 편집 후 재편집 시 original 이미지로 돌아가 수정할 수 없는 점은 따로 말씀드렸지만 다음에 논의해보기 위해 적어 놓아요
  • 대용량 이미지에 대응하여 큰 비트맵을 효율적으로 로드할 때 inJustDecodeBounds를 쓸 수 있다는 것을 알게 되었습니다 👍 inSampleSize 기법 써주신 것 좋습니다.
  • 이미지 정보 이용해서 회전 처리하는거 헷갈리는데 잘 처리해주셨네요 !! 😁
  • 비트맵 쓰고 메모리 해제하는 부분들과 캐시 키에 수정시간도 추가한 것도 확인 했습니다

Comment on lines +390 to +391
.memoryCacheKey(cacheKey)
.diskCacheKey(cacheKey)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ImageRequest 시 memoryCacheKey와 diskCacheKey를 둘 다 쓰는 이유가 있는지 궁금합니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

커스텀 캐시키를 사용하기 때문에, 동일한 캐시키를 사용함으로써 메모리 캐시와 디스크 캐시의 일관성 유지를 통해 안정적으로 동일한 이미지를 보여주기 위함입니다

@DongJun-H DongJun-H merged commit 4b8f27b into develop Aug 30, 2025
1 check passed
@github-project-automation github-project-automation bot moved this from Todo to Done in DAYO 2.0 Aug 30, 2025
@DongJun-H DongJun-H deleted the feature/issue-663 branch August 30, 2025 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants