
Flutter에서 Android 17 Contact Picker: READ_CONTACTS를 대체할 때
- David Serrano Canales
- 2026년 10월 19일
목차
Android 17에는 기기의 연락처를 읽는 방식에 중요한 변화가 생겼어요. 이제 매니페스트에 READ_CONTACTS 읽기 권한을 선언하지 않고도 시스템 피커를 띄워 연락처를 고르고 앱으로 가져올 수 있어요.
그래서 Google이 새로운 제한을 발표했어요. READ_CONTACTS 권한은 이제 시스템 피커만으로는 동작할 수 없는 앱에만 허용돼요. 즉 앱이 단순하게 연락처를 가져오는 정도라면 피커를 써야 해요. 앱이 동작하려면 권한이 꼭 필요한 경우라면, 피커로는 왜 부족한지 설명하는 Play Console 신고서를 제출해야 하고요. 정책은 2027년 1월 27일부터 시행돼요.
다만 이건 targetSdk 37 이상을 타깃으로 하는 앱에 해당해요. 아직 그 이전 버전을 타깃으로 한다면 당장은 영향이 없지만, 타깃을 올리는 순간 따라붙게 돼요.
이 글에서는 이게 무슨 의미인지, 어떤 영향을 받게 되는지, 그리고 정말 권한을 계속 써야 하는지 아니면 시스템 피커로 충분한지를 정리해 볼게요. 그다음에는 제가 직접 Android 17+에서 시스템 피커를 어떻게 구현했는지 코드 예제로 보여드릴게요.
Info
📽 영상 버전은 YouTube에서 볼 수 있어요
Google Play의 새 연락처 정책이 내 앱에도 해당될까요?
연락처에 접근하는 앱 대부분은 READ_CONTACTS 권한을 선언하지 않고도 시스템 피커로 아무 문제 없이 계속 동작할 거예요.
앱이 연락처에 가끔씩만 접근한다면, 예를 들어 앱 안으로 가져오거나 특정 시점에 연락처의 특정 정보 하나를 얻는 정도라면, 시스템 피커로 충분하고도 남아요.
차이는 범위예요. 권한으로는 주소록 전체를 읽었지만, 피커로는 사용자가 고른 연락처만 받게 되고, 게다가 필요한 필드만 골라서 요청할 수 있어요. 접근 권한도 일시적이라 선택한 내용을 다 읽고 나면 바로 만료돼요.
제 앱 부채 관리를 예로 들어 볼게요. 연락처를 만들고 거기에 빚과 결제를 붙이는 앱이에요. 연락처는 앱 안에서 직접 만들 수도 있고, 편하게 기기 주소록에서 가져올 수도 있어요. 제 유스케이스가 바로 ‘기기 주소록의 연락처를 앱 안으로 가져오기’라는 일회성 작업이에요.
시스템 피커면 저한테는 충분해요. 주소록에 끝없이 반복해서 접근할 필요는 없고, 특정 시점에 연락처를 가져와 앱 안에 저장할 일회성 접근만 있으면 되거든요.
⬆️ 이 영상에 보이는 피커가 Android 17의 새 네이티브 피커예요.
그래도 권한이 정당화되는 경우는 일회성 선택이 아니라 주소록 전체가 반복적으로 필요한 쪽이에요. 전화나 메시지 앱, 발신자 확인, 연락처 백업과 동기화 같은 것들이죠. 내 앱이 그런 쪽이 아니라면 피커로 충분할 가능성이 커요.
Flutter에서 Android 17 Contact Picker 구현하기
예제로 주소록의 연락처를 목록으로 보여주기만 하는 간단한 테스트 앱을 들어 볼게요.
연락처를 ‘기존 방식’으로 가져오려면 flutter_contacts 같은 플러그인을 쓰면 돼요.
final deviceContacts = await FlutterContacts.getAll(
properties: {ContactProperty.photoThumbnail},
);
이 코드로 이름과 이미지가 담긴 연락처 목록을 얻을 수 있어요. 다만 먼저 사용자에게 권한을 요청해야 해요. 그러지 않으면 호출이 아무것도 돌려주지 않아요.
final status = await FlutterContacts.permissions.request(PermissionType.read);
이제 Android 17+에 맞추는 데 필요한 작업을 해볼게요. 가장 먼저 할 일은 READ_CONTACTS 권한을 Android 16 이하 기기에만 선언하는 거예요.
<uses-permission
android:name="android.permission.READ_CONTACTS"
android:maxSdkVersion="36" />
그리고 Android 17 SDK로 컴파일해야 해요. ContactsPickerSessionContract 클래스가 이전 버전에는 없거든요. Flutter는 기본값이 아직 36이라 앱의 build.gradle.kts에서 직접 지정해 줘야 해요.
android {
compileSdk = 37
defaultConfig {
targetSdk = 37
}
}
Kotlin에서 ACTION_PICK_CONTACTS로 피커 띄우기
여기서 플러그인을 쓸 수도 있지만, 구현이 꽤 단순한 편이라 저는 플러그인을 하나 더 떠안지 않으려고 네이티브 구현을 택했어요. 네이티브 쪽과 Dart 쪽은 method channel로 대화하고, 시스템 피커는 Kotlin에서 호출하면서 다중 선택이 되도록 이렇게 설정해요.
val intent = Intent(ContactsPickerSessionContract.ACTION_PICK_CONTACTS).apply {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
putExtra(
ContactsPickerSessionContract.EXTRA_PICK_CONTACTS_SELECTION_LIMIT,
call.arguments as Int,
)
// Read back only the fields below instead of every data field.
putExtra(
ContactsPickerSessionContract.EXTRA_PICK_CONTACTS_MATCH_ALL_DATA_FIELDS,
false,
)
putStringArrayListExtra(
ContactsPickerSessionContract.EXTRA_PICK_CONTACTS_REQUESTED_DATA_FIELDS,
arrayListOf(
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE,
),
)
}
startActivityForResult(intent, PICK_CONTACTS_REQUEST_CODE)
저 call.arguments as Int가 선택 개수 제한이고, method channel을 통해 Dart에서 넘어와요.
사용자가 선택을 마쳐도 피커는 연락처를 바로 돌려주지 않고, 선택 결과를 가리키는 session URI를 줘요. 이걸 ContentResolver로 조회해야 하는데, 여기가 핵심이에요. 커서는 연락처당 한 줄이 아니라 요청한 필드마다 한 줄을 주거든요. 그래서 행을 묶어 줘야 해요.
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
val sessionUri = data?.data
if (resultCode != Activity.RESULT_OK || sessionUri == null) {
// The user left the picker without confirming a selection.
return
}
val projection = arrayOf(
ContactsContract.Contacts.LOOKUP_KEY,
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Photo.PHOTO,
)
contentResolver.query(sessionUri, projection, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
// Group the rows by LOOKUP_KEY, and keep the blob of the row whose
// MIMETYPE is Photo.CONTENT_ITEM_TYPE as the contact photo.
}
}
}
이 쿼리는 I/O라서 Dart에 응답하기 전에 메인 스레드 밖으로 빼두는 게 좋아요.
이 코드면 이름과 사진이 있는 연락처를 가져올 수 있고, 위 예제에는 이 이상은 필요 없어요.
선택한 연락처를 Dart에서 읽기
이어서 Dart 코드에서 시스템 피커를 써야 할지 기존 플러그인을 써야 할지 알려 줄 메서드를 정의해 볼게요. 그러려면 현재 Android 버전을 알아야 하는데, device_info_plus 플러그인으로 가능해요.
static const _systemPickerMinAndroidSdk = 37;
Future<bool> _isSystemPickerAvailable() async {
if (!Platform.isAndroid) return false;
final androidInfo = await DeviceInfoPlugin().androidInfo;
return androidInfo.version.sdkInt >= _systemPickerMinAndroidSdk;
}
이제 알맞은 방식으로 연락처를 가져올 수 있어요.
Future<List<AppContact>> loadContacts() async {
if (await _isSystemPickerAvailable()) {
return await _pickContactsWithSystemPicker();
}
return await _readContactsWithPermission();
}
Android 17+에서는 네이티브 쪽에서 연락처를 받아 파싱해요.
/// Opens the system Contact Picker. Returns an empty list if the user leaves
/// without selecting anything.
Future<List<AppContact>> _pickContactsWithSystemPicker() async {
final response = await _channel.invokeMapMethod<String, Object?>(
_methodPickContacts,
_maxSelectedContacts,
);
if (response == null || response['wasCancelled'] == true) {
return const [];
}
final contacts = <AppContact>[];
for (final rawContact in response['contacts'] as List<Object?>) {
final contact = rawContact as Map<Object?, Object?>;
final name = contact['name'] as String;
if (name.isEmpty) continue;
contacts.add(
AppContact(
id: contact['id'] as String,
name: name,
photo: contact['photo'] as Uint8List?,
),
);
}
return contacts;
}
마지막으로 하나만 더요. 피커는 앱이 아니라 사용자가 여는 거예요. 백그라운드에서 연락처를 새로 고치려고 마음대로 띄울 수 없으니, 선택 결과가 나중에 필요하면 저장해 두세요.
여기서는 핵심 부분만 보여드렸지만, 구현 전체를 보고 싶다면 프로젝트를 여기에서 확인할 수 있어요.
마무리
이 글에서는 연락처 읽기 권한에 대한 Google Play의 새 정책을 아주 빠르게 훑어보고, 제 앱에서 연락처를 다루려고 실제로 쓰고 있는 간단한 접근 방식을 보여드렸어요.
연락처 권한이 반드시 필요하다고 판단된다면, 정책이 시행되는 2027년 1월 27일 전에 Google Play에서 전용 양식을 제출해야 한다는 점 잊지 마세요.
다음에 또 만나요!