관리 메뉴

나만을 위한 블로그

[Android] 안드로이드 14에서 변경된 미디어 권한 대응 방법 본문

Android

[Android] 안드로이드 14에서 변경된 미디어 권한 대응 방법

참깨빵위에참깨빵 2024. 3. 3. 01:17
728x90
반응형

※ 이 글의 코드들은 모두 예시기 때문에 실제로 사용하려면 반드시 리팩토링 후 사용한다

 

안드로이드 14에서 미디어 접근 권한이 또 변경됐다. 작작 바뀌어라 진짜

어쩔 수 없다. 안드로이드 14에 맞춰 대응하려면 다시 공식문서 뒤적거리면서 뭔 소린지 애써 이해하고, 예시 코드 직접 써 보면서 고통받는 길밖에는 없다.

안드로이드 14의 변경점 중 특기할 만한 것으로는 제목에도 썼듯이 사진, 영상 파일에 대한 일부 접근 권한 부여다.

 

https://developer.android.com/about/versions/14/changes/partial-photo-video-access?hl=ko

 

사진 및 동영상에 대한 일부 액세스 권한 부여  |  Android Developers

The Android 15 Developer Preview is now available. Try it out today and let us know what you think! 이 페이지는 Cloud Translation API를 통해 번역되었습니다. 사진 및 동영상에 대한 일부 액세스 권한 부여 컬렉션을 사용해

developer.android.com

 

안드로이드 14부턴 자신들이 만든 Photo Picker를 쓰라는 눈치를 팍팍 준다. 문서 안의 예시 이미지 7장이 모두 Photo Picker다.

안드로이드 13 기기에서 실행하면 기본 폴더(카메라, 스크린샷, 다운로드) 외에 다른 폴더에서 사진을 가져올 수 있는 버튼도 제공하지 않는 미완성인 API를 왜 계속 쓰라고 하는지는 모르겠다.

각설하고, 안드로이드 13에선 아래 2가지의 새로운 권한이 추가됐었다.

 

  • READ_MEDIA_IMAGES
  • READ_MEDIA_VIDEO

 

그리고 안드로이드 14에선 이 2가지에 이어서 READ_MEDIA_VISUAL_USER_SELECTED 권한이 새로 추가됐다. 단어들을 뜯어보면 유저가 선택한 파일에만 접근할 수 있게 하는 권한 같은데, 이 권한의 문서 내용은 아래와 같다.

 

https://developer.android.com/reference/android/Manifest.permission#READ_MEDIA_VISUAL_USER_SELECTED

 

Manifest.permission  |  Android Developers

 

developer.android.com

앱에서 유저가 권한 프롬프트 photo picker를 통해 선택한 외부 저장소의 이미지 or 영상 파일을 읽을 수 있게 허용한다. 앱은 이 권한을 확인해서 유저가 photo picker를 사용하기로 결정했는지 확인하는 대신, READ_MEDIA_IMAGES 또는 READ_MEDIA_VIDEO에 대한 접근 권한을 부여할 수 있다. 앱이 표준 사진 선택기에 수동으로 접근하는 걸 막지는 않는다. 이 권한은 원하는 미디어 유형에 따라 READ_MEDIA_IMAGES 및 / 또는 READ_MEDIA_VIDEO와 함께 요청해야 한다.
이 권한은 targetSdk에 상관없이 앱이 READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, ACCESS_MEDIA_LOCATION을 요청하는 경우 매니페스트에 자동으로 추가된다. 앱이 이 권한을 요청하지 않으면 권한 부여 다이얼로그에서 READ_MEDIA_IMAGES 및 / 또는 READ_MEDIA_VIDEO에 대해 PERMISSION_GRANTED가 리턴되지만 앱은 유저가 선택한 미디어에만 접근할 수 있게 된다. 이 잘못된 권한 부여 상태는 앱이 백그라운드로 전환될 때까지 지속된다

보호 수준 : 위험

 

targetSdkVersion에 상관없이 안드로이드 13에서 추가된 2가지 권한을 매니페스트에 선언하면 이 권한은 자동으로 추가되는 모양이다.

그럼 이 권한이 추가되면서 갤러리에서 사진을 가져오는 프로세스가 어떻게 바뀌었을까? 디벨로퍼의 예시 코드를 바탕으로, 별도의 사진 선택 라이브러리 없이 권한을 허용한 사진 파일들을 가져와서 리사이클러뷰에 표시하는 예제를 확인해 본다. 데이터 바인딩은 기본이기 때문에 생략한다.

 

implementation 'com.github.bumptech.glide:glide:4.16.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'

 

이미지 표시할 거기 때문에 Glide 라이브러리를 추가해 준다.

그리고 매니페스트에 필요한 권한들을 추가해 준다. 디벨로퍼에서 제시한 권한 추가 예시는 아래와 같다.

 

<!-- Devices running Android 12L (API level 32) or lower  -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />

<!-- Devices running Android 13 (API level 33) or higher -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

<!-- To handle the reselection within the app on Android 14 (API level 34) -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />

 

그리고 디벨로퍼에서 제시한 Media data class를 만들어준다.

 

import android.net.Uri

data class Media(
    val uri: Uri,
    val name: String,
    val size: Long,
    val mimeType: String,
)

 

어댑터도 대충 만들어준다.

 

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.android14test.databinding.ItemMediaBinding

class MediaAdapter(
    private val mediaList: List<Media>
): RecyclerView.Adapter<MediaAdapter.MediaViewHolder>() {

    inner class MediaViewHolder(val binding: ItemMediaBinding) : RecyclerView.ViewHolder(binding.root)

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): MediaAdapter.MediaViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val binding: ItemMediaBinding = DataBindingUtil.inflate(layoutInflater, R.layout.item_media, parent, false)
        return MediaViewHolder(binding)
    }

    override fun onBindViewHolder(holder: MediaAdapter.MediaViewHolder, position: Int) {
        Glide.with(holder.binding.imageView.context)
            .load(mediaList[position].uri)
            .into(holder.binding.imageView)
    }

    override fun getItemCount(): Int = mediaList.size
}

 

어댑터에서 쓸 item_media.xml 파일도 만든다. 이미지만 표시할 거기 때문에 레이아웃도 생략한다.

 

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="300dp"
        android:layout_height="300dp"
        android:scaleType="centerCrop" />
</layout>

 

이제 메인 액티비티 코드들을 수정한다.

 

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rvImages"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <Button
            android:id="@+id/btnGetImage"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="30dp"
            android:text="이미지 가져오기"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/rvImages" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
import android.Manifest.permission.READ_MEDIA_IMAGES
import android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
import android.content.ContentResolver
import android.content.ContentUris
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.provider.MediaStore.Images
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.lifecycleScope
import com.example.android14test.databinding.ActivityMainBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class MainActivity : AppCompatActivity() {

    companion object {
        private val TAG = this::class.simpleName
    }

    private lateinit var binding: ActivityMainBinding
    private lateinit var requestPermissionLauncher: ActivityResultLauncher<Array<String>>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

        requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
            val newPermissionGranted = permissions["android.permission.READ_MEDIA_VISUAL_USER_SELECTED"] == true
            val readMediaImagesGranted = permissions["android.permission.READ_MEDIA_IMAGES"] == true
            if (newPermissionGranted && !readMediaImagesGranted) {
                lifecycleScope.launch {
                    val imageList = getImages(contentResolver)
                    binding.rvImages.apply {
                        adapter = MediaAdapter(imageList)
                    }
                    imageList.toMutableList().clear()
                }
            }
        }

        binding.run {
            lifecycleOwner = this@MainActivity

            btnGetImage.setOnClickListener {
                requestPermissionsIfNeeded()
            }
        }
    }

    private fun requestPermissionsIfNeeded() {
        when {
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> {
                requestPermissionLauncher.launch(arrayOf(READ_MEDIA_VISUAL_USER_SELECTED, READ_MEDIA_IMAGES))
            }
            else -> {
                // 안드로이드 14 미만 버전에서 권한 요청 로직
            }
        }
    }

    // Run the querying logic in a coroutine outside of the main thread to keep the app responsive.
    // Keep in mind that this code snippet is querying only images of the shared storage.
    private suspend fun getImages(contentResolver: ContentResolver): List<Media> = withContext(Dispatchers.IO) {
        val projection = arrayOf(
            Images.Media._ID,
            Images.Media.DISPLAY_NAME,
            Images.Media.SIZE,
            Images.Media.MIME_TYPE,
        )

        val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            // Query all the device storage volumes instead of the primary only
            Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
        } else {
            Images.Media.EXTERNAL_CONTENT_URI
        }

        val images = mutableListOf<Media>()

        contentResolver.query(
            collectionUri,
            projection,
            null,
            null,
            "${Images.Media.DATE_ADDED} DESC"
        )?.use { cursor ->
            val idColumn = cursor.getColumnIndexOrThrow(Images.Media._ID)
            val displayNameColumn = cursor.getColumnIndexOrThrow(Images.Media.DISPLAY_NAME)
            val sizeColumn = cursor.getColumnIndexOrThrow(Images.Media.SIZE)
            val mimeTypeColumn = cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE)

            while (cursor.moveToNext()) {
                val uri = ContentUris.withAppendedId(collectionUri, cursor.getLong(idColumn))
                val name = cursor.getString(displayNameColumn)
                val size = cursor.getLong(sizeColumn)
                val mimeType = cursor.getString(mimeTypeColumn)

                val image = Media(uri, name, size, mimeType)
                images.add(image)
            }
        }

        return@withContext images
    }
}

 

이렇게 작성하고 에뮬레이터에서 실행하면 아래와 같이 작동한다.

 

 

실제로 이 예시 코드를 에뮬레이터에서 실행해 보면 에러가 있어서, 여러 사진을 선택하면 조금 이상하게 표시된다.

실제 안드로이드 14 기기에서 실행해 보면 pixel 기반의 에뮬레이터와는 다르게 동작한다. 다르게 동작하는 부분은 아래와 같고, 실제로 확인하지 않으면 치명적인 부분이 많기 때문에 동작 테스트는 반드시 실기기로 할 것을 권장한다.

 

  • 권한을 허용할 사진 선택 후, 허용을 누르면 리사이클러뷰에 표시되는 건 동일하다
  • 그러나 다시 이미지 가져오기 버튼을 눌러 권한 프롬프트 photo picker가 표시될 때, 이전에 선택했던 사진이 선택된 채로 남아있다. 에뮬레이터에선 이런 표시가 나타나지 않는다
  • 앱 아이콘 롱클릭 > 정보 아이콘 클릭 > 권한 > '항상 확인' 밑의 사진 및 동영상 순으로 클릭하면 '항상 확인' 우측에 펜 아이콘이 있다. 이걸 누르면 내가 이 앱에서 어떤 파일에 권한을 허용했는지 볼 수 있고, 다른 파일들에 권한을 추가 허용하거나 제거할 수 있다. 에뮬레이터에선 이것이 불가능하다

 

3번에서 말한 펜 아이콘은 갤럭시 S24+ 기준으로 아래와 같이 나타난다.

 

 

이렇게 하면 안드로이드 14에서 이미지를 가져와 표시할 수 있다.

물론 그냥 표시할 뿐 아니라 추가 가공을 더 한 다음 표시하거나 보내는 경우도 많이 있을텐데, 다행히 Media data class에서 uri, name, size, mimeType의 기본적인 4가지 요소는 제공하기 때문에 이것만 사용해도 어지간한 커스텀 기능은 구현하거나 리팩토링에 용이하게 쓸 수 있을 것이다.

만약 부족하다면 MediaStore에서 뽑아올 수 있는 요소들을 Media data class에 추가한 다음 getImages()에서 원하는 요소들을 추가로 가져오도록 수정하면 될 것이다.

예를 들어 날짜, 시간, 위치 정보 등 추가적인 정보를 더 가져오고 싶다면 Media를 아래처럼 수정할 수 있다.

 

data class Media(
    val uri: Uri,
    val name: String,
    val size: Long,
    val mimeType: String,
    val dateAdded: Int?, // 파일이 추가된 시간 (UNIX 시간)
    val dateModified: Long?, // 파일이 수정된 시간 (UNIX 시간)
    val width: Int?, // 이미지의 너비
    val height: Int?, // 이미지의 높이
)

 

그리고 getImages()에서 추가한 정보들을 가져올 수 있도록 코드를 추가하면 된다.

 

private suspend fun getImages(contentResolver: ContentResolver): List<Media> = withContext(Dispatchers.IO) {
    val projection = arrayOf(
        Images.Media._ID,
        Images.Media.DISPLAY_NAME,
        Images.Media.SIZE,
        Images.Media.MIME_TYPE,
        Images.Media.DATE_ADDED,
        Images.Media.DATE_MODIFIED,
        Images.Media.WIDTH,
        Images.Media.HEIGHT,
    )

    val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        // Query all the device storage volumes instead of the primary only
        Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
    } else {
        Images.Media.EXTERNAL_CONTENT_URI
    }

    val images = mutableListOf<Media>()

    contentResolver.query(
        collectionUri,
        projection,
        null,
        null,
        "${Images.Media.DATE_ADDED} DESC"
    )?.use { cursor ->
        val idColumn = cursor.getColumnIndexOrThrow(Images.Media._ID)
        val displayNameColumn = cursor.getColumnIndexOrThrow(Images.Media.DISPLAY_NAME)
        val sizeColumn = cursor.getColumnIndexOrThrow(Images.Media.SIZE)
        val mimeTypeColumn = cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE)
        val dateAddedColumn = cursor.getColumnIndexOrThrow(Images.Media.DATE_ADDED)
        val dateModifiedColumn = cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED)
        val widthColumn = cursor.getColumnIndexOrThrow(Images.Media.WIDTH)
        val heightColumn = cursor.getColumnIndexOrThrow(Images.Media.HEIGHT)

        while (cursor.moveToNext()) {
            val uri = ContentUris.withAppendedId(collectionUri, cursor.getLong(idColumn))
            val name = cursor.getString(displayNameColumn)
            val size = cursor.getLong(sizeColumn)
            val mimeType = cursor.getString(mimeTypeColumn)
            val addedDate = cursor.getInt(dateAddedColumn)
            val modifiedDate = cursor.getInt(dateModifiedColumn).toLong()
            val width = cursor.getInt(widthColumn)
            val height = cursor.getInt(heightColumn)

            val image = Media(uri, name, size, mimeType, addedDate, modifiedDate, width, height)
            images.add(image)
        }
    }

    return@withContext images
}

 

 

반응형
Comments