안드로이드 - 비디오 썸네일 추출 및 서버 전송

이우건·2024년 2월 16일
0

안드로이드

목록 보기
12/20

여행을 가서 모임을 찾고 그 날의 일들을 기록하는 프로젝트를 수행하는 중에 갤러리에서 영상을 가져와서 썸네일을 보여주고 영상과 썸네일 모두 서버에 전송하는 기능을 구현해야 했었다.

AndroidManifest 권한 설정

<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에 같이 권한을 설정하였다.

Fragment.kt

갤러리에서 영상 가져오기

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)

갤러리에서 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)를 함수를 호출하였다.

비디오 Uri -> Bitmap으로 변환하는 함수

/**
     * 영상의 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도 따로 저장을 했다.

Bitmap을 ImageView에 렌더링

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에 렌더링 하였다.

비디오 Uri -> FilePath로 저장

서버에 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으로 반환한다.

ApiService.kt

 	@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이다.

UseCase.kt

DTO를 RequestBody로 변환하고 image, video filePath를 Multipart로 변환하는 과정은 UseCase에서 변환을 하고 Repository -> remoteDataSource -> service로 넘겨주었다.

RequestDTO -> RequestBody로 변환하는 함수

	/**
     * 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를 만들었다.

FilePath(String) -> MultipartBody.part로 변환

	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에 해당한다. 이를 서버와 맞춰주어야 한다.

전체 UseCase.kt

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)
    }
}
profile
머리가 나쁘면 기록이라도 잘하자

0개의 댓글