이미지 권한 설정 및 이미지 업로드 기능 구현 및 서버 통신에 대한 정리 글을 작성해보았습니다!
권한 설정
target sdk 33 부터 세분화되어 달라졌다.
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
Kotlin
복사
세분화된 이미지 권한 참고: 공식문서
실제 권한 설정 로직
PermissionManager
class PermissionManager(
private val fragment: Fragment,
private val onPermissionGranted: () -> Unit,
private val onPermissionDenied: () -> Unit,
) {
private val storagePermissions =
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
)
private val requestPermissionLauncher =
fragment.registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { permissions ->
if (permissions.values.all { it }) {
onPermissionGranted()
} else {
onPermissionDenied()
}
}
fun requestPermissions() {
if (isAndroid13OrAbove() || hasPermissions(fragment.requireContext(), storagePermissions)) {
onPermissionGranted()
} else {
requestPermissionLauncher.launch(storagePermissions)
}
}
fun isAndroid13OrAbove(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
private fun hasPermissions(
context: Context,
permissions: Array<String>,
): Boolean {
return permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
}
Kotlin
복사
Activity
permissionManager을 통해 권한 설정에 대한 로직만 구현
private lateinit var permissionManager: PermissionManager
private val viewModel: OfferingWriteViewModel by viewModels {
OfferingWriteViewModel.getFactory(
offeringRepository = (requireActivity().application as ChongdaeApp).offeringRepository,
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpPermissionManager()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
initBinding(inflater, container)
return fragmentBinding.root
}
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)
observeImageUploadEvent()
}
private fun setUpPermissionManager() {
permissionManager =
PermissionManager(
fragment = this,
onPermissionGranted = { onPermissionsGranted() },
onPermissionDenied = { onPermissionsDenied() },
)
}
private fun observeImageUploadEvent() {
viewModel.imageUploadEvent.observe(viewLifecycleOwner) {
permissionManager.requestPermissions()
}
}
private fun onPermissionsGranted() {
showToast(R.string.permission_granted)
pickImage()
}
private fun pickImage() {
// TODO: 이미지 선택 기능 구현 (글 아래 로직 참고)
}
private fun onPermissionsDenied() {
showToast(R.string.permission_denied)
}
Kotlin
복사
Viewmodel
xml 에서 클릭이벤트가 일어나면 imageUploadEvent를 업데이트시켜서 activity에 전달
private val _imageUploadEvent = MutableLiveData<Unit>()
val imageUploadEvent: LiveData<Unit> get() = _imageUploadEvent
fun onUploadPhotoClick() {
_imageUploadEvent.value = Unit
}
Kotlin
복사
xml에서 databinding을 사용하여 click을 전달할 수 있다.
android:onClick="@{() -> vm.onUploadPhotoClick()}"
Kotlin
복사
실제 에뮬레이터를 통한 버전별 권한 동작 확인
sdk 33
sdk 29
sdk26
photo picker
그런데 티라미슈부터(33)는 이미지 피커를 사용한다면 권한이 필요가 없다.
private lateinit var permissionManager: PermissionManager
private lateinit var pickMediaLauncher: ActivityResultLauncher<PickVisualMediaRequest>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpPermissionManager()
initializePhotoPicker()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
initBinding(inflater, container)
return fragmentBinding.root
}
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
observeImageUploadEvent()
}
private fun initializePhotoPicker() {
pickMediaLauncher = registerForActivityResult(PickVisualMedia()) { uri: Uri? ->
handleMediaResult(uri)
}
}
private fun launchPhotoPicker() {
pickMediaLauncher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))
}
private fun handleMediaResult(uri: Uri?) {
if (uri != null) {
Log.d("Picker", "URI: $uri")
} else {
Log.d("Picker", "아무것도 선택 안됨")
}
}
private fun setUpPermissionManager() {
permissionManager =
PermissionManager(
fragment = this,
onPermissionGranted = { onPermissionsGranted() },
onPermissionDenied = { onPermissionsDenied() },
)
}
private fun observeImageUploadEvent() {
viewModel.imageUploadEvent.observe(viewLifecycleOwner) {
// sdk33이상만 권한 허용 받기
if (permissionManager.isAndroid13OrAbove()) {
launchPhotoPicker()
} else {
permissionManager.requestPermissions()
}
}
}
private fun onPermissionsGranted() {
showToast(R.string.permission_granted)
launchPhotoPicker()
}
private fun onPermissionsDenied() {
showToast(R.string.permission_denied)
}
Kotlin
복사
이런식으로 uri가 제대로 들어오는 것을 확인할 수 있다.
FileUtils
uri → file → Multipart 로 변경하는 객체를 구현
object FileUtils {
fun getMultipartBodyPart(
context: Context,
uri: Uri,
paramName: String,
): MultipartBody.Part? {
val file = getFileFromUri(context, uri) ?: return null
return getMultipartBodyPart(file, paramName)
}
private fun getFileFromUri(
context: Context,
uri: Uri,
): File? {
val contentResolver: ContentResolver = context.contentResolver
val fileName = getFileName(contentResolver, uri)
val file = File(context.cacheDir, fileName)
try {
val inputStream: InputStream? = contentResolver.openInputStream(uri)
val outputStream = FileOutputStream(file)
inputStream?.copyTo(outputStream)
inputStream?.close()
outputStream.close()
} catch (e: Exception) {
e.printStackTrace()
return null
}
return file
}
private fun getFileName(
contentResolver: ContentResolver,
uri: Uri,
): String {
var name = ""
val returnCursor = contentResolver.query(uri, null, null, null, null)
returnCursor?.use {
if (it.moveToFirst()) {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
name = it.getString(nameIndex)
}
}
return name
}
private fun getMultipartBodyPart(
file: File,
paramName: String,
): MultipartBody.Part {
val requestBody = file.asRequestBody("multipart/form-data".toMediaTypeOrNull())
return MultipartBody.Part.createFormData(paramName, file.name, requestBody)
}
}
Kotlin
복사
Fragment에서 사용한 예시
private fun initializePhotoPicker() {
pickMediaLauncher =
registerForActivityResult(PickVisualMedia()) { uri: Uri? ->
handleMediaResult(uri)
}
}
private fun observeImageUploadEvent() {
viewModel.imageUploadEvent.observe(viewLifecycleOwner) {
if (permissionManager.isAndroid13OrAbove()) {
launchPhotoPicker()
} else {
permissionManager.requestPermissions()
}
}
}
private fun handleMediaResult(uri: Uri?) {
if (uri != null) {
val multipartBodyPart = FileUtils.getMultipartBodyPart(requireContext(), uri, "image")
if (multipartBodyPart != null) {
viewModel.uploadImageFile(multipartBodyPart)
} else {
showToast(R.string.error_file_conversion)
}
}
}
Kotlin
복사
viewmodel 에서 uploadImageFile
서버와 통신을 통해 image의 multipart를 전달한다.
api service
// @Multipart를 꼭! 추가해야한다.
@Multipart
@POST("/주소주소")
suspend fun postProductImageS3(
@Part image: MultipartBody.Part,
): Response<ProductUrlResponse>
Kotlin
복사
repository (또는 datasource) 에서 해당하는 로직을 구현한다.
suspend fun saveProductImageS3(image: MultipartBody.Part): Result<ProductUrlResponse>
Kotlin
복사
실제 구현 PR
실제 프로젝트에서 이미지 권한과 업로드에 대한 기능을 구현한 pull request