[안드로이드] 안드로이드 Q(API 29)이상에서 이미지 파일 다루기

박서현·2021년 6월 7일
6

안드로이드

목록 보기
8/9
post-thumbnail
post-custom-banner

안드로이드 빌드 버전 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();
        }
    }
profile
차곡차곡 쌓아가기
post-custom-banner

2개의 댓글

comment-user-thumbnail
2022년 5월 3일

👍

답글 달기
comment-user-thumbnail
2022년 6월 1일

멋진 포스트입니다. 도움이 되었습니다!

답글 달기