관리 메뉴

나만을 위한 블로그

[Android] 레트로핏으로 파일 업로드 중 java.io.FileNotFoundException: /external/video/media/1000000085: open failed: ENOENT (No such file or directory) 에러 해결 방법 본문

Android

[Android] 레트로핏으로 파일 업로드 중 java.io.FileNotFoundException: /external/video/media/1000000085: open failed: ENOENT (No such file or directory) 에러 해결 방법

참깨빵위에참깨빵 2024. 1. 21. 22:22
728x90
반응형

이 에러의 원인은 여러가지지만 둘 중 하나일 수 있다.

 

  • 파일 접근 권한 미허용
  • content://와 file:// Uri를 구분하지 않음

 

그러나 파일 접근 권한을 허용하지 않아서 이런 에러가 생기는 경우는 드물 것이라, Uri 스킴을 구분하지 않은 경우를 고려할 수 있다.

안드로이드의 Uri 스킴은 content, file로 시작하는 두 종류가 존재하는데 각각의 특징은 아래와 같다.

 

content:// scheme

 

  • 컨텐츠 프로바이더를 통해 데이터에 접근하기 위해 사용
  • 앱과 앱 사이에서 데이터 공유 시 사진, 영상, 연락처 등 다른 앱에서 관리하는 데이터에 접근할 때 사용
  • 파일의 실제 경로를 노출하지 않아서 보안 면에서 유리

 

file:// scheme

 

  • 파일 시스템 안의 특정 파일에 접근 시 사용
  • 앱 내부적으로 관리하는 파일 접근 시 주로 사용
  • 파일 경로를 노출하기 때문에 보안 이슈를 일으킬 수 있음

 

앱을 만들다 보면 사진, 영상 등 파일을 업로드하는 기능을 심심찮게 만들 수 있다. 이 기능을 구현하려면 직접 그 파일에 접근해서 가져와야 하는 경우가 발생할 수 있다.

페이스북, 인스타그램 등 외부 폴더의 파일 또는 인터넷에서 다운로드받은 파일을 업로드하는 경우가 그러한데, 라이브러리를 써서 파일을 가져오면 content:// 스킴을 얻는 경우가 있다. 이 파일을 그대로 레트로핏을 통해 업로드하면 이 포스팅 제목과 비슷한 에러를 볼 수 있다.

 

해결 방법은 아래와 같다.

 

  1. content:// uri 스킴을 실제 파일 경로로 변환
  2. InputStream을 써서 content:// uri 스킴에서 직접 데이터 읽기

 

이 포스팅에선 1번 방법을 확인한다. 아래 유틸 함수를 만든다.

 

fun getRealPathFromUri(contentUri: Uri, context: Context): String? {
    var cursor: Cursor? = null
    try {
        val proj = arrayOf(MediaStore.Video.Media.DATA)
        cursor = context.contentResolver.query(contentUri, proj, null, null, null)
        val columnIndex = cursor?.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)
        if (cursor != null && cursor.moveToFirst()) {
            return cursor.getString(columnIndex!!)
        }
    } finally {
        cursor?.close()
    }
    return null
}

 

이 함수는 매개변수로 받은 content:// uri를 실제 파일 경로로 변환한다. 리턴타입이 String?이기 때문에 "let?."을 쓰는 형태를 고려해볼 수 있다.

그리고 영상 업로드 시 아래와 같이 업로드할 수 있다

 

if (videoUri.scheme == "content") {
    // "content://" scheme에서 실제 파일 경로를 읽어올 때 앱 크래시가 발생하지 않게 코루틴 사용
    CoroutineScope(Dispatchers.IO).launch {
        getRealPathFromUri(videoUri, this@MainActivity)?.let { realPath ->
            withContext(Dispatchers.Main) { // withContext는 선택 사항
                fileUploadFunction(Uri.fromFile(File(realPath)))
            }
        } ?: run {
            // getRealPathFromUri()가 null을 리턴했을 때의 처리 추가
        }
    }
} else {
    fileUploadFunction(uri)
}

 

Uri에서 실제 파일 경로를 읽어올 때 ANR이 발생할 확률은 실제 파일 경로를 읽는 시간, 어떤 쓰레드에서 이 작업을 실행하냐에 따라 다르다. 디바이스 성능도 관건일 수 있겠다.

이 글을 찾아보고 있는 사람이라면 알겠지만 안드로이드는 싱글 쓰레드 운영체제기 때문에 긴 작업은 백그라운드 쓰레드에서 돌려주고, 메인 쓰레드는 그 결과를 UI에 반영하는 작업만 담당하게 해야 한다. 만약 메인 쓰레드가 5초 이상 응답하지 않을 경우 ANR이 발생한다.

이 에러를 막기 위해 코루틴을 써서 I/O 쓰레드에서 실제 파일 경로를 얻어온다. 이렇게 하면 메인 쓰레드가 차단당할 일이 없어지므로 안전하게 파일 경로를 얻을 수 있다는 장점이 있다.

 

withContext는 반드시 써야 하는 것은 아니다. 파일 업로드 함수에 메인 쓰레드에서 수행해야 하는 작업이 있다면 withContext를 써야 하지만 그게 아니라면 생략해도 된다.

예를 들어 파일 업로드 함수 안에 뷰모델의 LiveData를 업데이트하는 코드 같이 메인 쓰레드에서만 호출돼야 하는 코드가 있다면 java.lang.IllegalStateException: Method addObserver must be called on the main thread 에러가 발생하며 앱이 죽을 수 있다. 그러므로 본인의 비즈니스 로직에 따라 withContext를 추가할지를 결정해야 한다.

반응형
Comments