[Android] Android 11 대응 - 2. 범위 지정 저장소 적용

이 글은 실제로는 2021.01에 작성되어 일부 업데이트된 내용이 누락되었을 수 있음을 미리 밝힙니다.

1. 내부 저장소와 외부 저장소

내부 저장소에 저장된 파일은 애플리케이션 전용이며 다른 애플리케이션에서는 액세스 할 수 없다. 내부 저장소는 /data/data/com.example.testapp/ 이와 같은 경로를 말한다.

외부 저장소는 내부 저장소(UT) 또는 이동식 저장소의 어느 위치에나 있을 수 있다. 휴대전화의 OEM 및 Android OS 버전에 따라 달라질 수 있으므로 단말 기종마다 저장되는 위치가 다르다.

/sdcard 및 /storage/emulated/0 와 같은 형식의 패스는 외부 저장소를 가리킨다. 그러나 이것들은 /storage/sdcard0 대한 심볼릭 링크이기 때문에 (예전에는 된 것 같지만) 현재는 이와 같은 패스를 하드코딩하여 사용하지 않는 게 좋다.

2. 접근 권한

  • READ_EXTERNAL_STORAGE
  • WRITE_EXTERNAL_STORAGE

접근 권한은 위와 같이 잘 알려진 두 가지가 있으나 Android 11부터 WRITE_EXTERNAL_STORAGE만 권한허용을 해도 READ_EXTERNAL_STORAGE 권한은 자동으로 부여되도록 변경되었다.

Any app that declares the WRITE_EXTERNAL_STORAGE permission is implicitly granted this permission.
역> WRITE_EXTERNAL_STORAGE 권한을 허용한 앱은 내재적으로 READ_EXTERNAL_STORAGE 권한도 허용한다.

3. 안드로이드 P이하에서의 저장소 링크

  • 앱 데이터 폴더 : /storage/Android/data/[앱의 package name]/
  • 공용 폴더(DCIM, Pictures 등): /storage/[폴더 이름]

4. 안드로이드 Q이상에서의 범위 지정 저장소(Scoped Storage)

Android 10(API 수준 29) 이상을 타겟팅하는 앱에는 기본적으로 외부 저장소로 범위가 지정된 액세스 권한 또는 범위 지정 저장소가 부여됩니다. - 범위지정 저장소

Android 10에서는 선택적으로 범위지정 저장소를 사용하거나 사용하지 않을 수 있다.

Android 11(Q)이상부터는 강제로 바뀌는데, 범위지정 저장소는 아래와 같은 구조로 되어있다. 지칭하는 용어도 위치, 접근하는 방법(코드)도 완전히 바뀌기 때문에 Android 11 업데이트에서 가장 대응하기 난감했던 부분이다.

  • 앱 데이터 폴더(App specific directory)
  • 미디어 파일들(MediaStore)
  • 공용 파일들(Storage Access Framework)

5. 관련 이슈

java.io.FileNotFoundException: /storage/emulated/0/mediapicker/images/c.png: open failed: ENOENT (No such file or directory)

기존에 사용하던 파일 접근 로직을 사용하는 중이라면 위와 같이 FileNotFoundException ENOENT error를 보게 된다. 원래 읽어오던 파일 또는 파일의 위치에 접근할 수 없어서 “찾을 수 없다”는 에러를 띄우는 것이다.

6. 변경 사항

Android Developer - Scoped Storage

  • /sdcard 의 접근이 불가능해진다.
  • 외부 저장소의 최상위 경로를 반환하는 Enviroment.getExternalStorageDirectory()는 Android 10(API 29)부터 deprecated 되었고, 위에서 말한 것처럼 Android 11에서는 더이상 사용할 수 없다.

7. Android 10 호환성을 위해 유지할 것

Android 11에서는 해당 속성은 무시된다. 하지만 Android 10 호환성을 위해 requestLegacyExternalStorage=true 값을 유지해야 한다.

Android Developer - 범위 지정 저장소를 일시적으로 선택 해제

8. 문제가 될만 한 호출 메소드

다음 두 개의 메소드는 Android 10(API 29)부터 deprecated 되니 Context#getExternalFilesDir() 또는 MediaStore, Intent#ACTION_OPEN_DOCUMENT로 대체해야 한다.

Android Developer

(1) getExternalStorageDirectory

1
public static File Environment#getExternalStorageDirectory()

Context#getExternalFilesDir()로 대체한다.

이 방식을 사용하는 건 다음 두 개를 만족하는 경우이다.

  1. 앱이 삭제되면 같이 지워져도 된다.
  2. 외부에서 사용할 수 있도록 공공 저장소에 저장해도 된다.

(2) getExternalStoragePublicDirectory

1
public static File Environment#getExternalStoragePublicDirectory(String type)

이는 사진 및 영화와 같이 잘 알려진 파일 유형을 저장하기 위한 중앙 집중식 장소이다. 이 디렉토리와 내용은 앱을 제거할 때 삭제되지 않는다.(ex 위치: DCIM)

MediaStore 또는 Intent#ACTION_OPEN_DOCUMENT 사용하여 대체

ACTION_OPEN_DOCUMENT는 파일을 선택할 수 있는 파일 탐색기가 뜨도록 유도할 수 있으며, MediaStore를 사용한 예는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
SimpleDateFormat format1 = new SimpleDateFormat("yyyy-MM-dd_HH:mm:ss");
String fileName = format1.format(System.currentTimeMillis()) + ".jpg";

ContentValues values = new ContentValues();
// DCIM/ 또는 Pictures/ 이외의 장소에 접근하려고 하면 오류
values.put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/" + CommConstants.FOLDER_DIRECTORY);
values.put(MediaStore.Images.Media.DISPLAY_NAME, fileName);
values.put(MediaStore.Images.Media.MIME_TYPE, "image/*");
values.put(MediaStore.Images.Media.IS_PENDING, 1); // 파일을 write중이라면 다른곳에서 데이터요구를 무시

ContentResolver contentResolver = getContentResolver();
Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
Uri item = contentResolver.insert(collection, values);
ParcelFileDescriptor imageFile = null;
try {
// Uri(item)의 위치에 파일을 생성해준다.
imageFile = contentResolver.openFileDescriptor(item, "w", null);
if (imageFile != null) {
InputStream inputStream = getImageInputStream(bitmap);
byte[] strToByte = getBytes(inputStream);
FileOutputStream fos = new FileOutputStream(imageFile.getFileDescriptor());
fos.write(strToByte);
fos.close();
inputStream.close();
imageFile.close();
contentResolver.update(item, values, null, null);
}
} catch (IOException e) {
e.printStackTrace();
}

if (imageFile != null) {
values.clear();
values.put(MediaStore.Images.Media.IS_PENDING, 0); // 파일을 모두 write하고 다른곳에서 사용할 수 있도록 0으로 업데이트
contentResolver.update(item, values, null, null);
}
}

(3) MediaStore.MediaColumns.DATA 칼럼 접근 불가

query issue: invalid column latitude

MediaStore.MediaColumns.DATA 칼럼이 android 10부터 deprecated되어 접근할 수 없어서 발생하는 이슈이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static String getRealPathFromURI(Context context, Uri contentUri) throws SecurityException {
String res = null;
String[] proj = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DISPLAY_NAME};
Cursor cursor = context.getContentResolver().query(contentUri, proj, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
int columnIndex = (contentUri.toString().startsWith("content://com.google.android.gallery3d")) ?
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) :
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA);
res = cursor.getString(columnIndex);
cursor.close();
}
if (TextUtils.isEmpty(res)) {
res = contentUri.getPath();
}
return res;
}

따라서 이미지는 Q 미만일 때 위의 코드를 그대로 사용하면서, 비디오를 업로드할 경우는 아래의 코드를 통해 새로 카피본을 만들어 File을 받아 처리하도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static File getVideoFile(Context context, Uri contentUri) throws SecurityException {
ContentResolver contentResolver = context.getContentResolver();
String filePath = context.getApplicationInfo().dataDir + "/" + UPLOAD_FILE_NAME_ORIGIN_VIDEO;
File file = new File(filePath);
if (file.exists()) {
return file;
}
try {
InputStream inputStream = contentResolver.openInputStream(contentUri);
OutputStream outputStream = new FileOutputStream(file);
byte[] buf = new byte[1024];
int len = 0;
while((len = inputStream.read(buf)) > 0) {
outputStream.write(buf, 0, len);
}
outputStream.close();
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
return file;
}

9. 파일 저장 위치

이미지의 경우, 사진 및 스크린샷을 포함하며 DCIM/ 및 Pictures/ 디렉터리에 저장된다. 시스템은 이러한 파일을 MediaStore.Images 테이블에 추가한다. 즉, 파일 탐색기에서 Images 영역에 가면 저장한 파일을 확인할 수 있다.

만약 DCIM/ 또는 Pictures/ 이외의 장소에 접근하려고 하면 아래와 같은 에러가 뜬다.

Caused by: java.lang.IllegalArgumentException: Primary directory TestDir not allowed for content://media/external_primary/images/media; allowed directories are [DCIM, Pictures]

파일 탐색기 > Images

이미지 외의 파일 종류에 관해서는 Android Developer - 공유 저장소의 미디어 파일에 액세스를 참조할 것.

10. Android 10 애뮬레이터 테스트

그런데 이 설정을 하지 않아도 Android 11 애뮬레이터로 처음 실행했을 때 관련 이슈는 바로 재현 가능했으니 참고 하시기.

테스트를 위해 필요한 플래그는 다음의 두 가지이다.

  • DEFAULT_SCOPED_STORAGE(기본적으로 모든 앱에 사용 설정됨)
  • FORCE_ENABLE_SCOPED_STORAGE(기본적으로 모든 앱에 사용 중지됨)

FORCE_ENABLE_SCOPED_STORAGE 플래그를 설정하기 위해 시스템 > 고급 > 개발자 옵션 > 앱 호환성 변경사항 > 앱 > 에서 FORCE_ENABLE_SCOPED_STORAGE 항목을 찾아 on 시킨다.

개발자 옵션 > 앱 호환성 변경사항

[Android] Android 11 대응 - 2. 범위 지정 저장소 적용

https://dl137584.github.io/2022/02/16/017-android11-scoped-storage/

Author

LEEJS

Posted on

2022-02-16

Updated on

2022-05-02

Licensed under

댓글