Contact Picker ของ Android 17 ใน Flutter: ถึงเวลาแทนที่ READ_CONTACTS

Contact Picker ของ Android 17 ใน Flutter: ถึงเวลาแทนที่ READ_CONTACTS

สารบัญ

Android 17 มาพร้อมการเปลี่ยนแปลงสำคัญเรื่องการอ่านรายชื่อผู้ติดต่อบนเครื่อง ตอนนี้เราเรียก ตัวเลือกของระบบ ขึ้นมาเพื่อเลือกผู้ติดต่อและนำเข้าสู่แอปของเราได้ โดยไม่ต้องประกาศสิทธิ์การอ่าน READ_CONTACTS ใน manifest อีกต่อไป

ด้วยเหตุนี้ Google จึงประกาศข้อจำกัดใหม่ สิทธิ์ READ_CONTACTS ถูกสงวนไว้สำหรับแอปที่ไม่สามารถทำงานได้ด้วยตัวเลือกของระบบเพียงอย่างเดียว นั่นแปลว่า ถ้าแอปของคุณนำเข้าผู้ติดต่อแบบง่าย ๆ คุณจะต้องใช้ตัวเลือกนี้ แต่ถ้าสิทธิ์นั้นจำเป็นจริง ๆ ต่อการทำงานของแอป คุณจะต้องส่งคำชี้แจงใน Play Console อธิบายว่าทำไมตัวเลือกของระบบถึงไม่เพียงพอ นโยบายนี้มีผลวันที่ 27 มกราคม 2027

อย่างไรก็ดี เรื่องนี้ใช้กับแอปที่ตั้งเป้าไปที่ targetSdk 37 ขึ้นไป ถ้าแอปของคุณยังตั้งเป้าที่เวอร์ชันเก่ากว่านั้น นโยบายยังไม่กระทบคุณในตอนนี้ แต่จะตามมาทันทีที่คุณขยับ target ขึ้น

ในบทความนี้ผมจะพยายามให้แนวทางกับคุณว่าเรื่องนี้หมายถึงอะไร จะกระทบคุณอย่างไร และคุณจำเป็นต้องใช้สิทธิ์นั้นต่อไปจริงไหม หรือแค่ตัวเลือกของระบบก็พอแล้ว จากนั้นผมจะยกตัวอย่างโค้ดให้ดูว่าผมเองใช้ตัวเลือกของระบบบน Android 17+ ในแอปของตัวเองอย่างไร

Info

📽 มีเวอร์ชันวิดีโอบน YouTube

นโยบายผู้ติดต่อใหม่ของ Google Play กระทบแอปของคุณหรือเปล่า?

แอปส่วนใหญ่ที่เข้าถึงผู้ติดต่อจะยังทำงานได้ดีเหมือนเดิมด้วยตัวเลือกของระบบ โดยไม่ต้องประกาศสิทธิ์ READ_CONTACTS เลย

ถ้าแอปของคุณเข้าถึงผู้ติดต่อเป็นครั้งคราว เช่น เพื่อนำเข้ามาไว้ในแอป หรือเพื่อดึงข้อมูลบางอย่างของผู้ติดต่อคนหนึ่งในช่วงเวลาหนึ่ง ตัวเลือกของระบบก็เกินพอแล้ว

ความต่างอยู่ที่ขอบเขต ด้วยสิทธิ์นั้นคุณอ่านสมุดรายชื่อทั้งเล่ม ส่วนตัวเลือกของระบบจะให้เฉพาะผู้ติดต่อที่ผู้ใช้เลือกเท่านั้น แถมคุณยังขอเฉพาะฟิลด์ที่สนใจได้ด้วย การเข้าถึงก็เป็นแบบชั่วคราว หมดอายุทันทีที่คุณอ่านรายการที่เลือกเสร็จ

ลองดูแอปของผมเป็นตัวอย่าง ติดตามหนี้ เป็นแอปที่คุณสร้างผู้ติดต่อขึ้นมา แล้วผูกหนี้และการชำระเงินเข้ากับคนเหล่านั้น ผู้ติดต่อสร้างเองในแอปก็ได้ หรือจะเลือกนำเข้าจากสมุดรายชื่อของเครื่องเพื่อความสะดวกก็ได้ use case ของผมคือการทำงานครั้งเดียวจบพอดี นั่นคือ «ดึงผู้ติดต่อจากสมุดรายชื่อของเครื่องเข้ามาในแอป»

ตัวเลือกของระบบเหมาะกับผมมาก ผมไม่ต้องการการเข้าถึงสมุดรายชื่อแบบซ้ำ ๆ ไม่รู้จบ ผมต้องการแค่การเข้าถึงครั้งเดียวในช่วงเวลาหนึ่ง เพื่อดึงผู้ติดต่อเข้ามาและเก็บไว้ในแอปเท่านั้น

⬆️ ตัวเลือกที่คุณเห็นในวิดีโอนี้คือตัวเลือกเนทีฟตัวใหม่ของ Android 17

กรณีที่ยังมีเหตุผลรองรับสิทธิ์นั้นอยู่ คือกรณีที่ต้องใช้สมุดรายชื่อทั้งหมดซ้ำ ๆ ไม่ใช่การเลือกครั้งเดียว เช่น แอปโทรศัพท์หรือแชต การระบุผู้โทรเข้า หรือการสำรองและซิงก์ผู้ติดต่อ ถ้าแอปของคุณไม่ใช่แบบนั้น ก็มีแนวโน้มสูงว่าตัวเลือกของระบบจะพอ

การทำ Contact Picker ของ Android 17 ใน Flutter

ลองยกตัวอย่างแอปทดสอบง่าย ๆ ที่แค่แสดงรายชื่อผู้ติดต่อจากสมุดรายชื่อ:

รายชื่อผู้ติดต่อในแอปตัวอย่าง

ถ้าเราอยากได้ผู้ติดต่อแบบ «ดั้งเดิม» เราใช้ปลั๊กอินอย่าง 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" />

เราต้องคอมไพล์ด้วย SDK ของ Android 17 ด้วย เพราะคลาส 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 นั่นคือจำนวนสูงสุดที่เลือกได้ ซึ่งส่งมาจาก Dart ผ่าน method channel

เมื่อผู้ใช้เลือกเสร็จ ตัวเลือกจะไม่คืนผู้ติดต่อมาให้ตรง ๆ แต่คืน 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 เรื่องสิทธิ์การอ่านผู้ติดต่อกันอย่างรวดเร็ว และผมได้แสดงวิธีง่าย ๆ ที่ผมเองใช้ในการทำงานกับผู้ติดต่อในแอปของผม

ถ้าคุณคิดว่าจำเป็นต้องใช้สิทธิ์ผู้ติดต่อจริง ๆ อย่าลืมว่าคุณต้องกรอกแบบฟอร์มสำหรับเรื่องนี้ ใน Google Play ก่อนที่นโยบายจะมีผลในวันที่ 27 มกราคม 2027

แล้วพบกันใหม่คราวหน้า!