안드로이드 빌드 버전 Q (안드로이드 10, API 29) 이상부터 Scoped Storage가 적용되었다. 새로운 방법 사용해본다고 구글을 다 뒤졌다...
내부 vs 외부 저장소
Scoped Storage
👇안드로이드 Q 이전의 저장소 구조👇
👇안드로이드 Q 이후의 (Scoped Storage가 적용된) 저장소 구조👇
내부 저장소:
🔸 시스템과 앱들이 사용하는 저장 공간으로, 각 앱은 개별 저장 공간을 할당받는다. (그림에서 패키지명으로 표시된 공간)
경로: /data/user/0(userNumber)/com.app.a(패키지명)
접근: getApplicationInfo().dataDir
🔸 앱의 개별 저장 공간에는 cache, files 등의 폴더가 자동으로 생성되고, read/write에 대해 어떠한 권한도 필요하지 않다.
경로: /data/user/0(userNumber)/com.app.a(패키지명)/files
/data/user/0(userNumber)/com.app.a(패키지명)/cache
접근: getFilesDir().getAbsolutePath
getCacheDir().getAbsolutePath
🔸 앱이 삭제되면 함께 제거된다.
외부 저장소: (Scoped Storage에서 달라진 부분)
✔ 이전의 외부 저장소 :
외부 저장소가 앱 전용 공간과 공용 공간으로 나누어져 있었다. 외부 저장소를 read/write 할 수 있는 권한이 있으면, 누가 파일을 생성했는지, 어느 경로에 파일이 생성되었는지에 상관없이 모든 파일에 접근이 가능했다. 다른 앱이 READ_/WRITE_EXTERNAL_STORAGE 권한이 있으면, 자신의 앱 전용 공간에도 파일을 읽고, 쓸 수 있었다.
✔ Scoped Storage의 외부 저장소 :
사용자 정보 보호를 위해, '사진 및 동영상', '음악', '다운로드', '앱별 전용 공간'으로 나누어졌다.
☝ 앱 전용 공간
경로: /storage/emulated/0/Android/data/com.app.a/files/Pictures
/storage/emulated/0/Android/data/com.app.a/cache
접근: getExternalFilesDir(Environment.DIRECTORY_PICTURES).getAbsolutePath
getExternalCacheDir().getAbsolutePath
🔸 내부저장소와 비슷하다. 앱 마다 고유의 공간을 가지고 있고 권한 없이 read/write가 가능하며, 앱이 삭제되면 함께 제거된다.
🔸 다른 앱은 자신의 데이터 폴더에 접근 할 수 없다.
✌ '사진 및 동영상', '음악', '다운로드'로 이루어진 공용 공간
🔸 별다른 권한 없이, 어느 앱이나 파일을 생성할 수 있다. '다운로드' 공간은 파일의 타입 제한이 없다.
🔸 그러나, 마음대로 모든 파일을 읽지는 못한다. 자신이 생성한 파일은 권한 없이 접근할 수 있으나, 다른 앱이 생성한 파일을 읽기 위해서는 READ_EXTERNAL_STORAGE 권한 허가를 받아야 한다.
🔸 앱 삭제 여부와 관계 없이 계속 남아있다. 만약 앱 삭제시에도 파일을 보존하고 싶다면 MediaStore를 통해 파일을 생성한 후 메타데이터를 추가해야한다. manifest에 android:allowBackup="true”를 설정하면, 앱 정보를 구글 클라우드에 저장하게 되어, 앱을 재설치했을 때 클라우드에서 데이터를 복원할 수 있다.
결국, Scoped Storage에서는 WRITE_EXTERNAL_STORAGE 권한이 필요가 없어졌음을 알 수 있다. 권한 없이 파일을 생성할 수 있고, 다른 앱의 전용 공간에는 파일을 생성할 수 없기 때문이다.
사용할 수 있는 권한은 아래와 같다.
◾ MediaStore 사용할 경우 (10 버전에서 새로 생김)
READ_MEDIA_IMAGES
READ_MEDIA_VIDEO
READ_MEDIA_AUDIO
◾ MediaStore 혹은 MediaStore 외의 방법을 사용할 경우
READ_EXTERNAL_STORAGE (위의 3가지 권한을 아우르는 권한)
⭐ 다른 앱과 파일을 공유하는 방법
다른 앱과 파일을 공유하기 위해서는 FileProvider를 이용해서 해당 파일에 접근할 수 있는 Uri를 만들고, 파일을 공유받을 대상 앱에 임시로 Uri 접근(읽기 및 쓰기) 권한을 허용하는 방법을 사용해야 한다.
Uri의 형태는 아래와 같다.
content:// ...
file:/// ...
⭐매니페스트 파일
<application>
...
<provider
android:authorities="com.app.a"(패키지명)
android:name="androidx.core.content.FileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_path"/>
</provider>
...
</application>
⭐xml/file_path 파일
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="Android/data/com.app.a" -> 외부저장소의 앱 개별 공간 경로/>
</paths>
위처럼 설정해주면, 다른 어플에서 내 외부 저장소 파일에 Uri로 접근하는 것을 허용할 수 있다.
카메라로 찍은 사진 갤러리에 저장하기
카메라 접근 권한을 먼저 허가받아야 한다.
아래 코드는 카메라 권한이 있을 때 동작한다.
ActivityResultLauncher<Intent> cameraResultLauncher;
String imageName; // 카메라로 찍은 사진 이름
Uri imageUri; // 카메라로 찍은 사진 Uri
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_sample);
// 카메라에서 찍은 사진 처리
cameraResultLauncher = registerForActivityResult(new StartActivityForResult(), result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
if(imageUri != null) {
// 찍은 사진을 이미지뷰로 보여주는 메소드
makeImageView(imageUri.toString());
}
} else {
// 사진을 찍고 x 버튼을 누른 경우 등...
// 생성한 파일을 삭제한다. 삭제하지 않으면 갤러리에 빈 파일이 남아있다.
getContentResolver().delete(imageUri, null, null);
}
});
}
// 이미지 uri 생성 메소드
private Uri createImageUri(String fileName, String mimeType) {
ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, fileName); // 확장자가 붙어있는 파일명 ex) sample.jpg
values.put(MediaStore.Images.Media.MIME_TYPE, mimeType); // ex) image/jpeg
ContentResolver contentResolver = getContentResolver();
return contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
}
// 사진찍기 인텐트 시작하는 메소드
private void dispatchShootIntent() {
@SuppressLint("SimpleDateFormat") String timeStamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
imageName = "SAMPLE_"+timeStamp+".jpg";
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
imageUri = createImageUri(imageFileName, "image/jpeg");
intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);
cameraResultLauncher.launch(intent);
}
갤러리의 이미지를 서버로 업로드하기
이전에는 MediaStore.MediaColumns.DATA를 이용해서 파일의 절대경로를 얻을 수 있었는데, deprecated되었다. 따라서 갤러리의 이미지 파일을 내부 저장소에 파일에 복사한 다음, 이 파일 경로를 이용하여 서버에 업로드하였다. 사용한 메소드는 아래와 같다.
안드로이드Q 에뮬레이터에서 MediaStore.MediaColumns.DATA를 이용한 절대경로로 서버에 이미지를 업로드하려하면 php_file_error_code 3 (UPLOAD_ERR_PARTIAL - 파일이 일부분만 업로드 됨)이 발생한다.
// 서버에 업로드 할 이미지의 string Uri와 파일명이 담겨있는 리스트
ArrayList<GalleryModel> imageList = new ArrayList<>(); // GalleryModel은 내가 만든 클래스임
// 후에 삭제할 임시 파일 경로
ArrayList<String> tempFileList = new ArrayList<>();
/* 전체 갤러리 이미지 Uri, 파일명 등을 반환하는 메소드 (권한 있어야 함)*/
public void getGalleryImage(ArrayList<GalleryModel> galleryList) {
Cursor cursor;
String[] projection = {MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME};
String sortOrder = MediaStore.Images.Media._ID + " DESC"; // 최신순으로 가져오기
cursor = getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, sortOrder);
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID);
int displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME);
while (cursor.moveToNext()) {
long id = cursor.getLong(idColumn);
String displayName = cursor.getString(displayNameColumn);
Uri contentUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, String.valueOf(id));
galleryList.add(new GalleryModel(contentUri.toString(), displayName));
}
}
/* 이미지 파일을 복사한 후, 그 파일의 절대 경로 반환하는 메소드 */
public String createCopyAndReturnRealPath(Uri uri, String fileName) {
final ContentResolver contentResolver = getContentResolver();
if (contentResolver == null)
return null;
// 내부 저장소 안에 위치하도록 파일 생성
String filePath = getApplicationInfo().dataDir + File.separator + System.currentTimeMillis() + "." + fileName.substring(fileName.lastIndexOf(".")+1);
File file = new File(filePath);
// 서버에 이미지 업로드 후 삭제하기 위해 경로를 저장해둠
tempFileList.add(filePath);
try {
// 매개변수로 받은 uri 를 통해 이미지에 필요한 데이터를 가져온다.
InputStream inputStream = contentResolver.openInputStream(uri);
if (inputStream == null)
return null;
// 가져온 이미지 데이터를 아까 생성한 파일에 저장한다.
OutputStream outputStream = new FileOutputStream(file);
byte[] buf = new byte[1024];
int len;
while ((len = inputStream.read(buf)) > 0)
outputStream.write(buf, 0, len);
outputStream.close();
inputStream.close();
} catch (IOException ignore) {
return null;
}
return file.getAbsolutePath(); // 생성한 파일의 절대경로 반환
}
/* 서버에 이미지를 업로드하는 메소드(Volley+ 이용) */
private void uploadImage() {
// 안드로이드에서 보낼 데이터를 받을 php 서버 주소
String serverUrl="https://www.sample.com/save_image.php";
//파일 전송 요청 객체 생성
SimpleMultiPartRequest smpr= new SimpleMultiPartRequest(Request.Method.POST, serverUrl, new Response.Listener<String>() {
@Override
public void onResponse(String response) {
try {
JSONObject jsonObject = new JSONObject(response);
boolean success = jsonObject.getBoolean("success");
if(success) {
// 업로드 성공
Toast.makeText(WritePostActivity.this, "이미지가 업로드되었습니다.", Toast.LENGTH_SHORT).show();
// 임시파일 삭제
for(int i=0; i<tempFileList.size(); i++) {
File tempFile = new File(tempFileList.get(i));
if(tempFile.exists()) {
tempFile.delete();
}
}
} else {
// 업로드 실패
Toast.makeText(WritePostActivity.this, "이미지 업로드를 실패하였습니다.", Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
Toast.makeText(WritePostActivity.this, "서버와 통신 중 오류가 발생했습니다.", Toast.LENGTH_SHORT).show();
}
});
// 요청 객체에 이미지 파일 개수 추가
smpr.addStringParam("imageNum", String.valueOf(uriList.size()));
// 요청 객체에 이미지 파일 추가
if(imageList.size() != 0) {
for (int i = 0; i < imageList.size(); i++) {
smpr.addFile("image_"+i, createCopyAndReturnRealPath(Uri.parse(imageList.get(i).getUri()), imageList.get(i).getName()));
}
}
// 요청 객체를 서버로 보낼 객체 생성
RequestQueue requestQueue= Volley.newRequestQueue(this);
requestQueue.add(smpr);
}
서버에 업로드 된 이미지 파일 기기에 다운받기
아래의 메소드들을 사용해서 기기에 이미지를 저장했다.
Disposable backgoundTask;
/* 이미지 파일 갤러리에 저장하는 메소드 */
public void saveFile(@NonNull final File file, @NonNull final String mimeType, @NonNull final String displayName) throws IOException {
final ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName);
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// IS_PENDING을 1로 설정해놓으면, 현재 파일을 업데이트 전까지 외부에서 접근하지 못하도록 할 수 있다.
values.put(MediaStore.Images.Media.IS_PENDING, 1);
}
final ContentResolver resolver = getContentResolver();
Uri uri = null;
try {
final Uri contentUri;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
// 외부저장소-Downloads에 저장하고 싶을 때(Q 이상)
contentUri = MediaStore.Downloads.EXTERNAL_CONTENT_URI;
} else {
// 외부저장소-Pictures에 저장하고 싶을 때
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
}
uri = resolver.insert(contentUri, values);
if(uri == null) {
throw new IOException("Failed to create new MediaStore record.");
}
try (final OutputStream stream = resolver.openOutputStream(uri)) {
if(stream == null) {
throw new IOException("Failed to open output stream.");
}
// 파일을 바이트배열로 변환 후, 파일에 저장
byte[] bArray = convertFileToByteArray(file);
stream.write(bArray);
stream.flush();
stream.close();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.clear();
// 파일 저장이 완료되었으니, IS_PENDING을 다시 0으로 설정한다.
values.put(MediaStore.Images.Media.IS_PENDING, 0);
// 파일을 업데이트하면, 파일이 보인다.
resolver.update(uri, values, null, null);
Toast.makeText(this, "사진이 저장되었습니다.", Toast.LENGTH_SHORT).show();
}
}
}
catch (IOException e) {
Toast.makeText(DownloadImageActivity.this, "사진을 저장할 수 없습니다.", Toast.LENGTH_SHORT).show();
if (uri != null) {
resolver.delete(uri, null, null);
}
if(!backgroundTask.isDisposed()) {
backgroundTask.dispose();
}
throw e;
}
}
/* 파일을 바이트배열로 변환해주는 메소드 */
private static byte[] convertFileToByteArray(File file){
FileInputStream fis = null;
// Creating bytearray of same length as file
byte[] bArray = new byte[(int) file.length()];
try{
fis = new FileInputStream(file);
// Reading file content to byte array
fis.read(bArray);
fis.close();
}catch(IOException ioExp){
ioExp.printStackTrace();
}finally{
if(fis != null){
try {
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return bArray;
}
/* 백그라운드에서 서버 이미지 Url로 기기에 저장하는 메소드 */
private void saveImageFromUrl(String imageUrl) {
backgroundTask = Observable.fromCallable(() -> {
// Glide를 이용해서 서버의 Url로부터 이미지 파일 반환
return Glide.with(this).asFile().load(imageUrl).submit().get();
}).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(new Consumer<File>() {
@Override
public void accept(File file) throws Throwable {
// 이미지 파일 갤러리에 저장
saveFile(file, getImageMimeType(imageUrl), getImageName(imageUrl));
backgroundTask.dispose();
}
});
}
/* 파일 이름 반환하는 메소드 */
private String getImageName(String imageUrl) {
String[] name_1 = imageUrl.split("/");
// 확장자 포함된 이름 반환
return name_1[(name_1.length-1)];
}
/* mimeType 반환 */
private String getImageMimeType(String imageUrl) {
String[] type_arr = imageUrl.split("\\.");
if(type_arr[(type_arr.length-1)].toLowerCase().equals("jpg") ) {
return "image/jpeg";
} else {
return "image/"+type_arr[(type_arr.length-1)].toLowerCase();
}
}
👍