휴대폰에 있는 사진 (이미지)을 백업해 보아야겠다는 요청을 받았습니다. 하지만, 그때는 방법을 잘 모르겠더군요. 그래서 일단 찾아본다고 했는 데, 그 이후에는 더 이상의 요구를 하지 않았습니다. 그래서 이왕 찾아보았던 정보를 이용해서 앱을 하나 만들어 보기로 했습니다.
https://medium.com/@sendtosaeed2/android-fetch-all-files-from-local-storage-media-store-api-e9b9 14cd71e1
처리하는 기술적인 부분에 대해서는 이 글이 참고가 되었습니다. 안드로이드가 External Storage에 대한 access 권한을 제한하기 전에 안드로이드 11 이전에는 READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE 등으로 카메라 앱으로 촬영한 사진등의 이미지를 접근해 백업을 하거나 하는 처리를 할 수 있었습니다.
하지만, 안드로이드가 업데이트를 하는 과정에서 여러 가지 보안절차가 강화되었기 때문에 그걸 활용할 수 없었습니다.
https://developer.android.com/training/data-storage/shared/media?hl=ko
그래서 위 참조글에서 보았던 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
그걸 이용하면 이렇게 코드가 작성됩니다.
여기에 다가 android 13에서 어떻게 적용할 것인 가 하는 부분에 대한 참조글은 처음에 인용한 글에서 참고했습니다.
아무튼 이렇게 작성된 코드를 이용해서 앱 하나 만들어 playstore에 게시했습니다. ㅋ~
https://play.google.com/store/apps/details?id=com.billcorea.ftpclient0710
'모바일 앱(안드로이드)' 카테고리의 다른 글
안드로이드 앱 만들기 : java 에서 kotlin 으로 이전 SharedPreferences 는 어떻게 ? (4) | 2023.07.25 |
---|---|
안드로이드 앱 만들기 : java 프로젝트에서 kotlin 으로 넘어가 보기 (4) | 2023.07.24 |
안드로이드 앱 만들기 : 인앱 업데이트 appUpdateManager deprecated 해결해 보기 (8) | 2023.07.16 |
안드로이드 앱 만들기 : FTP Clietn 만들어 보기, 백업 앱 만들기 (2) | 2023.07.10 |
안드로이드 앱 만들기 : navigator backStack 지우기 (2) | 2023.07.07 |