Contact Picker của Android 17 trong Flutter: đã đến lúc thay READ_CONTACTS

Contact Picker của Android 17 trong Flutter: đã đến lúc thay READ_CONTACTS

Mục lục

Android 17 mang đến một thay đổi quan trọng trong việc đọc danh bạ của thiết bị: giờ đây ta có thể gọi một bộ chọn hệ thống để chọn liên hệ và nhập chúng vào app của mình, mà không cần khai báo quyền đọc READ_CONTACTS trong manifest.

Vì vậy, Google đã công bố các hạn chế mới: quyền READ_CONTACTS giờ chỉ dành cho những app không thể hoạt động nếu chỉ có bộ chọn hệ thống. Nghĩa là, nếu app của bạn nhập danh bạ theo cách đơn giản, bạn sẽ phải dùng bộ chọn. Nếu quyền đó là thiết yếu để app chạy được, bạn sẽ phải gửi một bản khai trong Play Console giải thích vì sao bộ chọn không đủ cho bạn. Chính sách có hiệu lực từ ngày 27 tháng 1 năm 2027.

Dù vậy, điều này áp dụng cho các app nhắm tới targetSdk 37 trở lên. Nếu app của bạn vẫn nhắm tới phiên bản cũ hơn, chính sách tạm thời chưa ảnh hưởng tới bạn, dù rồi nó cũng sẽ tới khi bạn nâng target.

Trong bài viết này tôi sẽ cố đưa cho bạn một hướng dẫn để hiểu chuyện này có ý nghĩa gì, nó sẽ ảnh hưởng tới bạn ra sao, và liệu bạn có thật sự cần tiếp tục dùng quyền đó hay bộ chọn hệ thống đã đủ. Sau đó, tôi sẽ đưa vài ví dụ code để cho bạn thấy tôi đã tự triển khai bộ chọn hệ thống trên Android 17+ trong các app của mình như thế nào.

Info

📽 Phiên bản video có trên YouTube

Chính sách danh bạ mới của Google Play có ảnh hưởng tới app của bạn không?

Phần lớn các app truy cập danh bạ sẽ vẫn chạy tốt với bộ chọn hệ thống, mà không cần khai báo quyền READ_CONTACTS.

Nếu ứng dụng của bạn chỉ truy cập danh bạ thỉnh thoảng, ví dụ để nhập chúng vào app, hoặc để lấy một dữ liệu cụ thể về một liên hệ tại một thời điểm nào đó, thì bộ chọn hệ thống là quá đủ.

Khác biệt nằm ở phạm vi: với quyền đó, bạn đọc toàn bộ danh bạ, còn với bộ chọn, bạn chỉ nhận được những liên hệ mà người dùng chọn, và hơn nữa bạn chỉ cần yêu cầu đúng những trường mình quan tâm. Quyền truy cập cũng chỉ là tạm thời, nó hết hiệu lực ngay khi bạn đọc xong phần đã chọn.

Lấy ví dụ app của tôi, Quản Lý Nợ. Một app nơi bạn tạo liên hệ rồi gán nợ và các khoản thanh toán cho họ. Liên hệ có thể tạo ngay trong app, hoặc bạn có thể chọn nhập từ danh bạ của thiết bị cho tiện. Use case của tôi đúng là một lần chạy duy nhất: «lấy danh bạ từ thiết bị vào trong app».

Bộ chọn hệ thống hợp với tôi hoàn hảo, tôi không cần quyền truy cập lặp đi lặp lại vô tận vào danh bạ; tôi chỉ cần một lần truy cập tại một thời điểm nhất định để lấy danh bạ về và lưu lại trong app.

⬆️ Bộ chọn bạn thấy trong video này chính là bộ chọn native mới của Android 17.

Những trường hợp vẫn thật sự biện minh được cho quyền đó là những trường hợp cần toàn bộ danh bạ một cách thường xuyên, chứ không phải một lựa chọn nhất thời: app gọi điện hay nhắn tin, nhận diện cuộc gọi, hoặc sao lưu và đồng bộ danh bạ. Nếu app của bạn không thuộc kiểu đó, nhiều khả năng bộ chọn là đủ.

Triển khai Contact Picker của Android 17 trong Flutter

Lấy ví dụ một app thử nghiệm đơn giản chỉ liệt kê các liên hệ của thiết bị:

Danh sách liên hệ trong app ví dụ

Nếu muốn lấy danh bạ theo cách «truyền thống», ta có thể dùng một plugin như flutter_contacts:

final deviceContacts = await FlutterContacts.getAll(
  properties: {ContactProperty.photoThumbnail},
);

Đoạn code này sẽ cho ta danh sách liên hệ kèm tên và ảnh. Tuy nhiên, trước đó bạn phải xin quyền từ người dùng, nếu không lời gọi sẽ chẳng trả về gì:

final status = await FlutterContacts.permissions.request(PermissionType.read);

Giờ hãy thực hiện những điều chỉnh cần thiết cho Android 17+. Đầu tiên là khai báo quyền READ_CONTACTS chỉ cho các thiết bị chạy Android 16 trở xuống:

<uses-permission
  android:name="android.permission.READ_CONTACTS"
  android:maxSdkVersion="36" />

Ta cũng cần biên dịch với SDK của Android 17, vì lớp ContactsPickerSessionContract không tồn tại ở các phiên bản trước. Flutter vẫn dùng 36 theo mặc định, nên phải chỉnh tay trong build.gradle.kts của app:

android {
    compileSdk = 37

    defaultConfig {
        targetSdk = 37
    }
}

Gọi bộ chọn từ Kotlin bằng ACTION_PICK_CONTACTS

Ta cũng có thể dùng một plugin nào đó cho việc này, nhưng vì phần triển khai khá đơn giản, tôi thích làm native hơn để khỏi phụ thuộc thêm một plugin nữa. Phía native và phía Dart nói chuyện với nhau qua một method channel, và chính từ Kotlin ta gọi bộ chọn hệ thống, cấu hình cho phép chọn nhiều như sau:

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)

Cái call.arguments as Int đó là giới hạn số lượng chọn, được truyền từ Dart sang qua method channel.

Khi người dùng xong việc, bộ chọn không trả về danh bạ trực tiếp, mà trả về một session URI trỏ tới phần đã chọn. Ta phải truy vấn nó bằng ContentResolver, và đây là chi tiết quan trọng: con trỏ trả về cho ta một hàng cho mỗi trường được yêu cầu, chứ không phải một hàng cho mỗi liên hệ, nên phải gom nhóm các hàng lại:

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.
        }
    }
}

Nhớ rằng truy vấn này là I/O, nên tốt nhất hãy đưa nó ra khỏi luồng chính trước khi trả lời cho Dart.

Với đoạn code này ta lấy được các liên hệ kèm tên và ảnh, không cần gì thêm cho ví dụ ở trên.

Đọc các liên hệ đã chọn trong Dart

Tiếp theo, trong code Dart, hãy định nghĩa một phương thức cho ta biết nên dùng bộ chọn hệ thống hay plugin truyền thống. Muốn vậy ta cần biết phiên bản Android hiện tại, và có thể làm điều đó bằng plugin 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;
}

Có vậy rồi, ta đã có thể lấy danh bạ bằng đúng cơ chế phù hợp:

Future<List<AppContact>> loadContacts() async {
  if (await _isSystemPickerAvailable()) {
    return await _pickContactsWithSystemPicker();
  }

  return await _readContactsWithPermission();
}

Trên Android 17+, ta lấy danh bạ từ phía native rồi parse:

/// 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;
}

Một điều cuối cần nhớ: bộ chọn do người dùng mở, không phải do app. Bạn không thể tự gọi nó để làm mới danh bạ dưới nền, nên nếu sau này cần tới phần đã chọn, hãy lưu nó lại.

Ở đây tôi chỉ cho bạn xem những phần cốt lõi, nhưng để hiểu rõ hơn cách triển khai, bạn có thể xem toàn bộ dự án tại đây.

Kết luận

Trong bài viết này ta đã điểm qua rất nhanh chính sách mới của Google Play về quyền đọc danh bạ, và tôi đã cho bạn thấy một cách tiếp cận đơn giản mà chính tôi đang dùng để làm việc với danh bạ trong các app của mình.

Nếu bạn thấy mình nhất định cần quyền danh bạ, hãy nhớ rằng bạn phải điền biểu mẫu dành riêng cho việc đó trên Google Play trước khi chính sách có hiệu lực, ngày 27 tháng 1 năm 2027.

Hẹn gặp lại lần sau!