/ ANDROID, SCOPED STORAGE, CAMERA

카메라 사진촬영으로 보는 Scoped storage 예제 및 소스

Target/Compile Sdk version에 Api level 30을 적용한 아주 간단한 사진촬영 (요청) 앱입니다. 이 앱의 목표는 다음과 같습니다.

  • Android 10(Q)이후 디바이스에 요구되는 Scoped storage일때 사진 촬영 요청 및 Media Store 등록.
  • 안드로이드 앱에서 최소한의 permission으로 어떻게 사진촬영/저장 기능을 제공하는지 보여줍니다.

소스 : https://github.com/mond-al/example-camera-capture

앱 소스 해설

manifest 살펴보기

먼저 살펴볼 부분은 permission부분입니다.

    <uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="28" />

사진 촬영과 저장기능을 한다고 해서 WRITE_EXTERNAL_STORAGE가 꼭 필요하지는 않습니다. 하지만, 촬영한 이미지가 media store에 등록되어야 한다면 Android Q미만의 장치에서는 외부 저장소의 쓰기 기능을 사용해야합니다. 가령 사진을 촬영하고 간단한 가공을 위해 파일로 생성하지만, 사용자의 갤러리에 등록하지 않고 기능만 제공하다면 WRITE_EXTERNAL_STORAGE를 정의할 필요가 없습니다.

그리고 위 구문에서 주의깊게 볼 부분은 maxSdkVersion="28"입니다. Scoped Storage가 적용된 경우 WRITE_EXTERNAL_STORAGE를 요청해도 모두 무시되며, 권한없음을 반환하게 됩니다. 때문에 명시적으로 최대 Api level 28 까지만 WRITE_EXTERNAL_STORAGE를 적용합니다.

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

applicaionId에 “.fileprovider”를 붙인 authorities를 명시하는 구문입니다. Android 7이후부터는 Intent를 통해 파일 경로를 넘겨줄 때 file provider를 이용해야합니다. 이때 전달 할 수 있는 파일의 범주는 android:resource="@xml/file_paths"의 file_paths.xml 파일에서 관리하게 됩니다. 내부저장소의 앱의 영역 뿐만아니라 Environment.getExternalStorageDirectory()로 생성한 파일의 경로를 전달할때도 content://com.al.mond.example.camera.fileprovider/external_files/filename.ext 처럼 실제 파일의 경로(absolute path)는 감추어지게 되고 com.al.mond.example.camera.fileprovider에 의해 제공 됩니다. 물론 external_files 부분을 특정 대치하여 사용 할 수 있겠지만 이 값은 고정되어 있는 값이 아니기 때문에 그렇게 사용해서는 안됩니다.

<?xml version="1.0" encoding="utf-8"?>
<path>
    <cache-path name="files" path="."/>
</path>

위와 같이 정의하여 internal storage의 cache영역을 file provider로 제공 할 수 있게 됩니다. internal storage나 다른 영역을 정의하고 싶다면 File Provider Path를 참고하시기 바랍니다.

<queries>
    <intent>
        <action android:name="android.media.action.IMAGE_CAPTURE" />
    </intent>
</queries>

Android 11부터는 Manifest에 정의된 action을 정의해야 합니다. 주의 할 점은 과 동일한 Depth에 정의되야야합니다. 만약 queries에 정의하지 않으면 `Intent.resolveActivity()`를 호출했을때 항상 null을 반환합니다. 이는 Android 11부터 보여지는 강력한 보안정책과 관련있습니다. 예전에는 이 앱이 어떤 인텐트를 처리 할 수 있는지를 명시하는 "기능"적이 정보만 담고 있었다면, 지금은 이 앱이 어떤 요청을 할 것인지 설치전 부터 명세가 가능해 집니다. 이런 기조를 보면 Apple AppStore처럼 Google Play도 앞으로는 등록 과정이나 검수 과정이 꽤 험난해 질 것 같습니다.

MainActivity.kt

사실상 이 앱의 모든 기능이 정의 되어 있는 부분입니다. 최대한 심플하게 구현했기 때문에 코루틴이나 RX 또는 쓰레드 컨트롤은 과감하게 생략하였습니다.

private fun doCapture() {
    val permissionList: Array<String> = getMustRequiredPermissions() // (1)
    when {
        shouldShowRequestPermissionRationale(permissionList) -> {    // (2)
            Toast.makeText(this, "must permission(s)", Toast.LENGTH_SHORT).show()
        }
        getNotGrantedPermissions(permissionList).isNotEmpty() -> {   // (3)
            ActivityCompat.requestPermissions(this, permissionList, RequestCode.CapturePermissions.ordinal)
        }
        else -> {
            requestCapture() // (4)
        }
    }
}

(1) Android 10이상에서는 권한이 필요 없습니다. 때문에 빌드 버전에 따라 필요한 권한을 분기합니다. (2) 명시적으로 거부된 권한이 있으면, 더 이상 진행 할 수 없음을 사용자에게 알립니다. (3) 아직 거부 또는 허용되지 않은 권한이 있다면 요청 합니다. (4) 권한에 문제가 없다면 실제 촬영 요청 코드로 진입합니다.

private var extraOutputFile: File? = null  // (1)
private fun requestCapture() {
    val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    if (takePictureIntent.resolveActivity(this.packageManager) != null) {  // (2)
        val dateTime = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
        extraOutputFile = File(cacheDir, "${dateTime}.jpg")                // (3)
        val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", extraOutputFile!!) // (4)
        takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, uri)
        startActivityForResult(takePictureIntent, RequestCode.Capture.ordinal)
    }
}

(1) 생성한 내부저장소 파일의 경로를 저장하기 위해 불가피하게 전역변수를 추가합니다. (2) Android 11(R)에서 manifest에 정의되어 있는 queries-intent-action항목이 있기 때문에 원하는 결과를 받아 올 수 있습니다. (3) ACTION_IMAGE_CAPTURE는 onActivityResult에서 인자로 넘어오는 intent에서 MediaStore.EXTRA_OUTPUT로 요청 했던 uri를 다시 받아 올수 없습니다. 참고로 ACTION_VIDEO_CAPTURE는 onActivityResult에서 넘어오는 intent.data를 이용 할 수 있습니다. (4) FileProvider.getUriForFile를 이용하여 FileProvider가 처리 할 수 있는 uri를 생성합니다.
ex) content://com.al.mond.example.camera.fileprovider/files/20210212_023102.jpg 예제에서 com.al.mond.example.camera.fileprovider가 authority입니다.

RequestCode.Capture.ordinal -> {
    extraOutputFile?.let { originalCapturedImageFile ->
        val contentValues = ContentValues().apply {
            put(MediaStore.Images.Media.TITLE, originalCapturedImageFile.name)
            put(MediaStore.Images.Media.SIZE, originalCapturedImageFile.length())
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM) (1)
            contentValues.put(MediaStore.Images.Media.IS_PENDING, 1)   // (2)
            val contentUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)  // (3)
            if (contentUri != null) {
                contentResolver.openFileDescriptor(contentUri, "w")?.use { pfd ->
                    val outputStream = FileOutputStream(pfd.fileDescriptor)
                    copyFromTo(FileInputStream(extraOutputFile), outputStream)
                    contentValues.clear()
                    contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)  // (4)
                    contentResolver.update(contentUri, contentValues, null, null)
                }
                viewModel.uri.value = contentUri
            }
        } else {
            val externalFile = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), originalCapturedImageFile.name) // (A)
            copyFromTo(FileInputStream(extraOutputFile), FileOutputStream(externalFile))
            contentValues.put(MediaStore.Images.Media.DATA, externalFile.absolutePath)   // (B)
            val contentUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
            if (contentUri != null) {
                viewModel.uri.value = contentUri
            }
        }
        if (originalCapturedImageFile.delete())
            Toast.makeText(this, "Removed the garbage file. ", Toast.LENGTH_SHORT).show()
    }
}

촬영을 종료하고 ResultCode가 RESULT_OK일때 일어나는 코드흐름입니다. (1) RELATIVE_PATH에 Environment.DIRECTORY_DCIM을 정의합니다. 단순히 촬영된 사진이기 때문에 DIRECTORY_PICTURES보다 적합해 보입니다. DIRECTORY_PICTURES에 저장되어도 기능상으로는 관계 없지만 맥락에 맞처서 사용하는것이 좋겠습니다. (2) 아직 외부저장소에 파일이 복사되지 않았기 때문에 Media Store를 통해 외부에 공개되지 않도록 비트 플래그로 막아둡니다. 혹시 파일을 복사하는 도중에 어떠한 문제로 인해 중단된다면, Media store에 파일 정보만 존재하는 쓰래기 row를 남기게 됩니다. 이럴때는 contentResolver.delete를 이용하여 명확하게 제거 하는 로직이 있다면 쓰레기 데이터를 줄 일 수 있을 것 입니다.
(3)contentResolver.insert를 통해 먼저 Media store에 row를 추가합니다. 이 앱에서 쓰기권한이 추가된 파일의 경로를 uri로 전달 받게 됩니다. 실제 파일의 위치(absolutePath)는 알 수 없습니다.

(A) Android 10(Q)미만의 디바이스에서는 File을 통해 실제 경로에 파일을 생성합니다. 여기서 주의 할 점은 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)context.getExternalFilesDir(Environment.DIRECTORY_DCIM)는 완전히 다른 경로를 반환하기 때문에 헷갈려서는 안됩니다.(안드로이드 파일 쓰기를 참고.)
(B) 직접 외부저장소의 미디어 영역에 파일을 복사한 이후에 contentResolver.insert를 수행하여 Media Store에 row를 추가합니다. DATA 필드에 실제 파일의 위치(absolutePath)를 명시해야 합니다.

Search

Get more post