Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

안드로이드 앱 만들기 : MediaStore API 활용해 보기 (feat 휴대폰 사진 백업)

Billcorea 2023. 7. 18. 18:03
반응형
활용앱 예시

휴대폰에 있는 사진 (이미지)을 백업해 보아야겠다는 요청을 받았습니다.  하지만, 그때는 방법을 잘 모르겠더군요. 그래서 일단 찾아본다고 했는 데, 그 이후에는 더 이상의 요구를 하지 않았습니다.  그래서 이왕 찾아보았던 정보를 이용해서 앱을 하나 만들어 보기로 했습니다. 
 
https://medium.com/@sendtosaeed2/android-fetch-all-files-from-local-storage-media-store-api-e9b9 14cd71e1

Android Fetch All Files From Local Storage (Media Store API) 🥲

Hi android devs! Hope you are all fine but not fine with the media store api, cause it is good but not good for us. In this article we will…

medium.com

 
처리하는 기술적인 부분에 대해서는 이 글이 참고가 되었습니다. 안드로이드가 External Storage에 대한 access 권한을 제한하기 전에 안드로이드 11 이전에는 READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE 등으로 카메라 앱으로 촬영한 사진등의 이미지를 접근해 백업을 하거나 하는 처리를 할 수 있었습니다.
 
하지만, 안드로이드가 업데이트를 하는 과정에서 여러 가지 보안절차가 강화되었기 때문에 그걸 활용할 수 없었습니다.
 
https://developer.android.com/training/data-storage/shared/media?hl=ko 

공유 저장소의 미디어 파일에 액세스  |  Android 개발자  |  Android Developers

DataStore는 로컬 데이터를 저장하는 최신 방법을 제공합니다. SharedPreferences 대신 DataStore를 사용해야 합니다. 자세한 내용은 DataStore 가이드를 참고하세요. 공유 저장소의 미디어 파일에 액세스 컬

developer.android.com

그래서 위 참조글에서 보았던 MediaStore API을 활용해 보기로 했습니다. 권한 설정 등의 제한 사항이 발견되었기 때문에 안드로이드 13을 타깃으로 설정했습니다. 
 

gradle
defaultConfig {
    applicationId "com.billcorea.......t0710"
    minSdk 33
    targetSdk 34
    versionCode 7
    versionName "0.0.7"

    testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    vectorDrawables {
        useSupportLibrary true
    }
}

minSdk 33으로 설정 했습니다. 아직 안드로이드 13까지 패치되지 않은 기기들이 많기는 하겠지만, 뭐 금방 다 옮겨 오지 않을까 싶습니다.  물론 지구상의 모든 안드로이드 기기를 대상으로 하겠다면 달라질 수 있습니다. 
 
다만, 한국에서 팔리는 기기등은 대부분 안드로이드 13으로 패치가 되었을 거라는 믿음(?)은 강하게 당겨 옵니다. 
 

manifest
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>

manifest에  등록한 권한 목록입니다. notify을 하기 위한 권한,  wifi 상태 확인을 위한 권한등은 기능설정을 위해서 필요한 부분이고, 
 
READ_MEDIA_IMAGES 가 필수 권한입니다.  READ_EXTERNAL_STOREAGE 은 안드로이드 API 32까지만 필요하므로 이제 지워도 될 것 같습니다.  이제 데이터를 읽어 오는 부분에 대한 코드를 작성해 보겠습니다. 
 

withContext(Dispatchers.IO) {

            /**
             * Android [ContentProvider]로 작업할 때 핵심 개념은
             * "투영". 프로젝션은 공급자에게 요청할 열의 목록입니다.
             * (정확히) SQL의 "SELECT ..." 절로 생각할 수 있습니다.
             * 성명.
             *
             * 프로젝션을 제공하는 것은 _필수_가 아닙니다. 이 경우 `null`을 전달할 수 있습니다.
             * [ContentResolver.query]에 대한 호출에서 `projection` 대신 요청하지만
             * 필요한 것보다 더 많은 데이터는 성능에 영향을 미칩니다.
             *
             * 이 샘플에서는 몇 개의 데이터 열만 사용하므로
             * 열의 하위 집합.
             */
            /**
             * Android [ContentProvider]로 작업할 때 핵심 개념은
             * "투영". 프로젝션은 공급자에게 요청할 열의 목록입니다.
             * (정확히) SQL의 "SELECT ..." 절로 생각할 수 있습니다.
             * 성명.
             *
             * 프로젝션을 제공하는 것은 _필수_가 아닙니다. 이 경우 `null`을 전달할 수 있습니다.
             * [ContentResolver.query]에 대한 호출에서 `projection` 대신 요청하지만
             * 필요한 것보다 더 많은 데이터는 성능에 영향을 미칩니다.
             *
             * 이 샘플에서는 몇 개의 데이터 열만 사용하므로
             * 열의 하위 집합.
             */
            val projection = arrayOf(
                MediaStore.Images.Media._ID,
                MediaStore.Images.Media.DISPLAY_NAME,
                MediaStore.Images.Media.DATE_ADDED,
            )

            /**
             * `selection`은 SQL 문의 "WHERE ..." 절입니다. 가능하다
             * 대신 `null`을 전달하여 이를 생략하면 모든 행이 반환됩니다.
             * 이 경우 이미지를 촬영한 날짜를 기준으로 선택 항목을 사용하고 있습니다.
             *
             * 선택 항목에 `?`가 포함되어 있습니다. 이것은 변수를 나타냅니다.
             * 다음 변수에 의해 제공됩니다.
             */
            /**
             * `selection`은 SQL 문의 "WHERE ..." 절입니다. 가능하다
             * 대신 `null`을 전달하여 이를 생략하면 모든 행이 반환됩니다.
             * 이 경우 이미지를 촬영한 날짜를 기준으로 선택 항목을 사용하고 있습니다.
             *
             * 선택 항목에 `?`가 포함되어 있습니다. 이것은 변수를 나타냅니다.
             * 다음 변수에 의해 제공됩니다.
             */
            val selection = "${MediaStore.Images.Media.DATE_ADDED} >= ?"

            /**
             * `selectionArgs`는 각 `?`에 대해 채워질 값 목록입니다.
             * `선택`에서.
             */
            /**
             * `selectionArgs`는 각 `?`에 대해 채워질 값 목록입니다.
             * `선택`에서.
             */
            val selectionArgs = arrayOf(
                dateToTimestamp(1, 1, 2000).toString()
            )
            selectionArgs.forEach {
                Log.e("", "selectionArgs $it ${getDateTime(it)}")
            }

            /**
             * Sort order to use. This can also be null, which will use the default sort
             * order. For [MediaStore.Images], the default sort order is ascending by date taken.
             */
            /**
             * Sort order to use. This can also be null, which will use the default sort
             * order. For [MediaStore.Images], the default sort order is ascending by date taken.
             */
            val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"

            context.contentResolver.query(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                projection,
                selection,
                selectionArgs,
                sortOrder
            )?.use { cursor ->

                /**
                 * 반환된 [Cursor]에서 데이터를 가져오려면
                 * 관심 있는 각 열과 일치하는 인덱스를 찾습니다.
                 *
                 * 두 가지 방법이 있습니다. 첫 번째는 방법을 사용하는 것입니다
                 * 열 ID를 찾을 수 없는 경우 -1을 반환하는 [Cursor.getColumnIndex]. 이것
                 * 코드가 요청할 열을 프로그래밍 방식으로 선택하는 경우에 유용합니다.
                 * 그러나 객체로 파싱하기 위해 단일 방법을 사용하고 싶습니다.
                 *
                 * 우리의 경우 원하는 열을 정확히 알고 있기 때문에
                 * 반드시 포함되어야 한다는 점(API 1에서 모두 지원되기 때문에)
                 * [Cursor.getColumnIndexOrThrow]를 사용합니다. 이 방법은
                 * [IllegalArgumentException] 명명된 열을 찾을 수 없는 경우.
                 *
                 * 두 경우 모두 이 방법이 느리지는 않지만 결과를 캐시하려고 합니다.
                 * 각 행에 대해 조회할 필요가 없도록 합니다.
                 */
                /**
                 * 반환된 [Cursor]에서 데이터를 가져오려면
                 * 관심 있는 각 열과 일치하는 인덱스를 찾습니다.
                 *
                 * 두 가지 방법이 있습니다. 첫 번째는 방법을 사용하는 것입니다
                 * 열 ID를 찾을 수 없는 경우 -1을 반환하는 [Cursor.getColumnIndex]. 이것
                 * 코드가 요청할 열을 프로그래밍 방식으로 선택하는 경우에 유용합니다.
                 * 그러나 객체로 파싱하기 위해 단일 방법을 사용하고 싶습니다.
                 *
                 * 우리의 경우 원하는 열을 정확히 알고 있기 때문에
                 * 반드시 포함되어야 한다는 점(API 1에서 모두 지원되기 때문에)
                 * [Cursor.getColumnIndexOrThrow]를 사용합니다. 이 방법은
                 * [IllegalArgumentException] 명명된 열을 찾을 수 없는 경우.
                 *
                 * 두 경우 모두 이 방법이 느리지는 않지만 결과를 캐시하려고 합니다.
                 * 각 행에 대해 조회할 필요가 없도록 합니다.
                 */
                val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
                val dateModifiedColumn =
                    cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
                val displayNameColumn =
                    cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)

                while (cursor.moveToNext()) {

                    // Here we'll use the column indexs that we found above.
                    val id = cursor.getLong(idColumn)
                    val dateModified =
                        Date(TimeUnit.SECONDS.toMillis(cursor.getLong(dateModifiedColumn)))
                    val displayName = cursor.getString(displayNameColumn)

                    /**
                     * 이것은 가장 까다로운 부분 중 하나입니다.
                     *
                     * 이미지에 액세스하고 있기 때문에(사용하여
                     * [MediaStore.Images.Media.EXTERNAL_CONTENT_URI], 이를 사용하겠습니다.
                     * 기본 URI로 이미지의 ID를 추가합니다.
                     *
                     * 이것은 [MediaStore.Video]로 작업할 때와 완전히 동일한 방법이며
                     * [MediaStore.Audio]도 마찬가지입니다. `Media.EXTERNAL_CONTENT_URI`가 무엇이든
                     * 아이템을 얻기 위한 쿼리가 기본이고, ID는 받을 문서입니다.
                     * 거기에 요청하십시오.
                     */

                    /**
                     * 이것은 가장 까다로운 부분 중 하나입니다.
                     *
                     * 이미지에 액세스하고 있기 때문에(사용하여
                     * [MediaStore.Images.Media.EXTERNAL_CONTENT_URI], 이를 사용하겠습니다.
                     * 기본 URI로 이미지의 ID를 추가합니다.
                     *
                     * 이것은 [MediaStore.Video]로 작업할 때와 완전히 동일한 방법이며
                     * [MediaStore.Audio]도 마찬가지입니다. `Media.EXTERNAL_CONTENT_URI`가 무엇이든
                     * 아이템을 얻기 위한 쿼리가 기본이고, ID는 받을 문서입니다.
                     * 거기에 요청하십시오.
                     */

                    val contentUri = ContentUris.withAppendedId(
                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                        id
                    )

                    val image = MediaStoreImage(id, displayName, dateModified, contentUri)
                    images += image

                    // For debugging, we'll output the image objects we create to logcat.
//                    Log.e("", "Added image: $displayName $dateModified ${TimeUnit.SECONDS.toMillis(cursor.getLong(dateModifiedColumn))}")
                }
            }

이 코드는 android 개발자 페이지의 open 된 코드들 중에서 storeage-samples 안에 있는 MediaStore 프로젝트에서 가지고 왔음을 밝혀 둡니다.
 
https://github.com/android/storage-samples

GitHub - android/storage-samples: Multiple samples showing the best practices in storage APIs on Android.

Multiple samples showing the best practices in storage APIs on Android. - GitHub - android/storage-samples: Multiple samples showing the best practices in storage APIs on Android.

github.com

그걸 이용하면 이렇게 코드가 작성됩니다. 
 
여기에 다가 android 13에서 어떻게 적용할 것인 가 하는 부분에 대한 참조글은 처음에 인용한 글에서 참고했습니다. 
 
아무튼 이렇게 작성된 코드를 이용해서 앱 하나 만들어 playstore에 게시했습니다. ㅋ~
https://play.google.com/store/apps/details?id=com.billcorea.ftpclient0710 

사진백업 - Google Play 앱

NAS 로 내 폰의 사진을 백업해 드립니다. 자동으로 해 드릴 생각 이에요

play.google.com

 

반응형