여행을 가서 모임을 찾고 그 날의 일들을 기록하는 프로젝트를 수행하는 중에 갤러리에서 영상을 가져와서 썸네일을 보여주고 영상과 썸네일 모두 서버에 전송하는 기능을 구현해야 했었다.
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- 안드로이드 13이상에서는 READ_STORAGE가 다음과 같이 분리됨 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
이 프로젝트의 targetSDK는 33 (안드로이드 13)으로 지정되어있기 때문에 READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE는 deprecated되고
READ_MEDIA_IMAGES
READ_MEDIA_VIDEO 로 변경되었다.
하지만 사용자 중에 안드로이드 13미만의 사용자도 있을 수 있으므로 AndroidManifest에 같이 권한을 설정하였다.
private fun openGallery() {
binding.btnPictureAdd.setOnClickListener {
if (!permissionGallery()) {
val videoIntent = Intent(Intent.ACTION_PICK, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
getResult.launch(videoIntent)
}
}
}
갤러리는 프로젝트 앱과 다른 앱이기 때문에 intent로 접근해서 그 결과를 받아와야 한다.
Intent.ACTION_PICK은 사용자가 데이터의 한 조각을 선택하기 위한 액션이다.
MediaStore.Video.Media.EXTERNAL_CONTENT_URI는 외부 저장소에 있는 모든 비디오의 컨텐츠 URI를 나타낸다.
private fun permissionGallery() : Boolean{
// 13 이상일 때
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val readImagePermission = ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.READ_MEDIA_IMAGES)
val readVideoPermission = ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.READ_MEDIA_VIDEO)
return if (readImagePermission == PackageManager.PERMISSION_DENIED || readVideoPermission == PackageManager.PERMISSION_DENIED){
ActivityCompat.requestPermissions( // activity, permission 배열, requestCode
requireActivity(), arrayOf(
android.Manifest.permission.READ_MEDIA_IMAGES,
android.Manifest.permission.READ_MEDIA_VIDEO),
1
)
true
} else {
false
}
// 13 이하일 때
} else {
val writePermission = ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
val readPermission = ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.READ_EXTERNAL_STORAGE)
return if (writePermission == PackageManager.PERMISSION_DENIED || readPermission == PackageManager.PERMISSION_DENIED){
ActivityCompat.requestPermissions( // activity, permission 배열, requestCode
requireActivity(), arrayOf(
android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
android.Manifest.permission.READ_EXTERNAL_STORAGE),
1
)
true
} else {
false
}
}
}
안드로이드 사용자의 레벨에 따라 권한체크 확인을 분기로 나눴다. (Build.VERSION_CODES.TIRAMISU => 안드로이드 13)
if (권한 DENIED)로 설정하였으므로 true를 반환하면 권한을 할 수 있게하는 다이얼로그를 띄우도록 설정해야한다.
갤러리에서 Intent로 영상을 가져올 때 getResult.launch(videoIntent)
로 getResult를 실행시켰을 것이다.
lateinit var getResult: ActivityResultLauncher<Intent>
private var filePath = ""
// intent 결과를 받음 (Fragment onCreate 함수에서 선언함)
getResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val data = result.data
data?.data?.let { uri ->
val thumbnailBitmap = getVideoThumbnail(uri)
setThumbnailImage(thumbnailBitmap)
filePath = getVideoPathUri(uri)
}
}
}
이전에는 startActivityForResult와 onActivityResult 콜백 메소드를 사용하여 intent의 결과를 받아왔지만 이 방식은 deprecated되어 registerForActivityResult로 intent의 결과를 받아왔다.
result를 성공적으로 받으면 let 함수로 비디오 uri가 null이 아닐 때 썸네일을 추출하는 getVideoThumbnail(uri)를 함수를 호출하였다.
/**
* 영상의 1초 시간을 bitmap으로 반환하는 함수
*/
private var thumbnailImagePath = ""
private fun getVideoThumbnail(uri : Uri) : Bitmap? {
val thumbnailTime = 1
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(requireContext(), uri)
// 마이크로 시간으로 계산해서 10^6을 곱해줘야됨.
val bitmap = retriever.getFrameAtTime((thumbnailTime * 1000000).toLong(), MediaMetadataRetriever.OPTION_CLOSEST)
// bitmap을 절대 경로 파일에 저장
val timestamp = System.currentTimeMillis() // 중복을 피하기 위해 현재 시간을 넣어줌
val thumbnailFileName = "thumbnail_$timestamp.jpg"
val thumbnailFile = File(requireContext().cacheDir, thumbnailFileName)
val fos = FileOutputStream(thumbnailFile)
bitmap?.compress(Bitmap.CompressFormat.JPEG, 100, fos)
fos.close()
thumbnailImagePath = thumbnailFile.absolutePath
Log.d(TAG, "getVideoThumbnail: $thumbnailImagePath")
return bitmap
} catch (e : IllegalArgumentException){
e.printStackTrace()
Log.d(TAG, "IllegalArgumentException: ${e.message}")
} catch (e : RuntimeException){
e.printStackTrace()
Log.d(TAG, "RuntimeException: ${e.message}")
} catch (e : IOException) {
Log.d(TAG, "IOException: ${e.message}")
} finally {
try {
retriever.release()
} catch (e : RuntimeException){
e.printStackTrace()
Log.d(TAG, "RuntimeException2: ${e.message}")
}
}
return null
}
Uri를 Bitmap으로 변환은 MediaMetadataRetriever() 클래스를 사용하여 변환할 수 있었다. MediaMetadataRetriever는 안드로이드에서 미디어 파일의(비디오, 오디오) 메타데이터를 추출하는데 사용된다.
setDataSource 함수를 통해 MediaMetadataRetriever 인스턴스에 데이터 소스를 설정한다. 오버로딩으로 다양한 매개변수를 받을 수 있었는데 fragment에서는 context와 Uri를 넘겨줘서 설정할 수 있었다.
데이터 소스를 설정하면 getFrameAtTime 함수로 비디오의 특정 프레임 구간을 따서 Bitmap을 반환할 수 있었다.
Bitmap을 전역 변수 thumbnailImagePath 파일 절대 경로에 저장한 이유는 서버에 전송을 할 때 MultipartBody.part로 변환해서 보내야하기 때문에 파일 형태로 보내야 하기 때문에 return으로 bitmap을 return하고 thumbnailImagePath도 따로 저장을 했다.
private fun setThumbnailImage(bitmap: Bitmap?) {
if (bitmap != null) {
binding.ivPictureAdd.visibility = View.GONE
Glide.with(this)
.asBitmap()
.load(bitmap)
.into(binding.btnPictureAdd)
}
}
Glide를 이용하여 Bitmap을 ImageView에 렌더링 하였다.
서버에 Multipart로 전송하기 위해 Uri를 FilePath로 변환하는 과정이 필요했다.
/**
* 파일 경로를 찾는 함수
*/
private fun getVideoPathUri(uri: Uri) : String{
val buildName = Build.MANUFACTURER
// 샤오미 폰은 바로 경로 반환 가능
if (buildName.equals("Xiaomi")) {
return uri.path.toString()
}
var columnIndex = 0
val proj = arrayOf(MediaStore.Video.Media.DATA)
var cursor = requireActivity().contentResolver.query(uri, proj, null, null, null)
if (cursor!!.moveToFirst()){
columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)
}
return cursor.getString(columnIndex)
}
ContentResolver의 커서를 이용하여 해당 데이터가 어디에 있는지 인덱스 별로 찾고 찾았으면 쿼리 결과를 String으로 반환한다.
@Multipart
@POST("/personal-record")
suspend fun makeRecord(
@Part image : MultipartBody.Part?,
@Part video : MultipartBody.Part?,
@Part("sPRRDto") makeRecordRequestDTO : RequestBody
) : Response<IsSuccessResponseDTO>
Retrofit을 사용하여 Multipart로 데이터를 보내기 위해서는 @Multipart를 붙이고 DTO는 RequestBody로 변환해서 전송해야한다.
데이터 보내는 순서도 맞춰주어야한다.
@Part("sPRRDto") makeRecordRequestDTO : RequestBody 부분에 "sPRRDto"은 서버에서 요구하는 key이고 makeRecordRequestDTO가 Value이다.
DTO를 RequestBody로 변환하고 image, video filePath를 Multipart로 변환하는 과정은 UseCase에서 변환을 하고 Repository -> remoteDataSource -> service로 넘겨주었다.
/**
* RequestDTO -> RequestBody로 변환
*/
private fun createRequestBody(recordRequestDTO: RecordRequestDTO): RequestBody {
val gson = Gson()
val productJson = gson.toJson(recordRequestDTO)
return productJson.toRequestBody("application/json".toMediaTypeOrNull())
}
private fun convertImageMultiPart(image : String): MultipartBody.Part {
val file = File(image)
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
return MultipartBody.Part.createFormData("image", file.name, requestFile)
}
DTO를 gson으로 감싸서 Json형태로 만들고 contentType을 "application/json"으로 지정해서 RequestBody를 만들었다.
private fun convertImageMultiPart(image : String): MultipartBody.Part {
val file = File(image)
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
return MultipartBody.Part.createFormData("image", file.name, requestFile)
}
private fun convertVideoMultiPart(video : String): MultipartBody.Part {
val file = File(video)
val requestFile = file.asRequestBody("video/*".toMediaTypeOrNull())
return MultipartBody.Part.createFormData("video", file.name, requestFile)
}
FilePath를 통해 File()로 변환하고 Multipart로 변환해준다.
MultipartBody.Part.createFormData("image", file.name, requestFile)
MultipartBody.Part.createFormData("video", file.name, requestFile)
모두 createFormData() 함수에 첫번째 인자로 String이 들어가 있는데 이는 서버에서 설정한 key, value 중 key에 해당한다. 이를 서버와 맞춰주어야 한다.
class MakeRecordUseCase @Inject constructor(
private val pamphletRepositoryImpl: PamphletRepository
) {
suspend fun makeRecord(image : String, video : String, recordRequestDTO : RecordRequestDTO) : IsSuccessResponseDTO?{
val requestBody = createRequestBody(recordRequestDTO)
var multiImage : MultipartBody.Part? = null
var multiVideo : MultipartBody.Part? = null
if (image.isNotEmpty()) {
multiImage = convertImageMultiPart(image)
}
if (video.isNotEmpty()) {
multiVideo = convertVideoMultiPart(video)
}
Log.d(TAG, "requestBody: $requestBody")
Log.d(TAG, "multiImage: $multiImage")
Log.d(TAG, "multiVideo: $multiVideo")
val response = pamphletRepositoryImpl.makeRecord(multiImage, multiVideo, requestBody)
Log.d(TAG, "makeRecord: $response")
if (response.isSuccessful) {
val body = response.body()!!
return body
}
return null
}
/**
* RequestDTO -> RequestBody로 변환
*/
private fun createRequestBody(recordRequestDTO: RecordRequestDTO): RequestBody {
val gson = Gson()
val productJson = gson.toJson(recordRequestDTO)
return productJson.toRequestBody("application/json".toMediaTypeOrNull())
}
private fun convertImageMultiPart(image : String): MultipartBody.Part {
val file = File(image)
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
return MultipartBody.Part.createFormData("image", file.name, requestFile)
}
private fun convertVideoMultiPart(video : String): MultipartBody.Part {
val file = File(video)
val requestFile = file.asRequestBody("video/*".toMediaTypeOrNull())
return MultipartBody.Part.createFormData("video", file.name, requestFile)
}
}