Contact Picker w Androidzie 17 i Flutter: czas zastąpić READ_CONTACTS

Contact Picker w Androidzie 17 i Flutter: czas zastąpić READ_CONTACTS

Spis treści

Android 17 przynosi ważną zmianę w odczycie kontaktów z urządzenia: od teraz można wywołać systemowy selektor, żeby wybrać kontakty i zaimportować je do naszej aplikacji, bez deklarowania uprawnienia do odczytu READ_CONTACTS w manifeście.

W związku z tym Google ogłosiło nowe ograniczenia: uprawnienie READ_CONTACTS zostaje zarezerwowane dla tych aplikacji, które nie są w stanie działać z samym selektorem systemowym. Oznacza to, że jeśli twoja aplikacja importuje kontakty w prosty sposób, będziesz musiał użyć selektora. Jeśli uprawnienie jest niezbędne, żeby aplikacja działała, będziesz musiał wysłać deklarację w Play Console z uzasadnieniem, dlaczego selektor ci nie wystarcza. Zasady wchodzą w życie 27 stycznia 2027.

Przy czym dotyczy to aplikacji celujących w targetSdk 37 lub wyższy. Jeśli twoja celuje jeszcze we wcześniejszą wersję, zasady na razie cię nie dotyczą, choć dopadną cię, gdy tylko podniesiesz target.

W tym artykule spróbuję dać ci przewodnik, żebyś zrozumiał, co to oznacza, jak cię dotknie i czy naprawdę musisz dalej używać uprawnienia, czy wystarczy ci selektor systemowy. Potem pokażę ci kilka przykładów kodu, jak sam zaimplementowałem selektor systemowy na Androidzie 17+ we własnych aplikacjach.

Info

📽 Wersja wideo dostępna na YouTube

Czy nowe zasady Google Play dotyczące kontaktów mają wpływ na twoją aplikację?

Większość aplikacji, które sięgają po kontakty, będzie dalej działać bez zarzutu z selektorem systemowym, bez deklarowania uprawnienia READ_CONTACTS.

Jeśli twoja aplikacja sięga po kontakty doraźnie, na przykład żeby je zaimportować do aplikacji albo pobrać konkretną informację o kontakcie w danym momencie, to selektor systemowy w zupełności wystarczy.

Różnica leży w zakresie: z uprawnieniem czytałeś całą książkę adresową, a z selektorem dostajesz wyłącznie kontakty wybrane przez użytkownika — i w dodatku możesz poprosić tylko o te pola, które cię interesują. Dostęp jest też tymczasowy, więc wygasa, gdy tylko skończysz czytać zaznaczenie.

Weźmy za przykład moją aplikację Kontroler Długów. Aplikację, w której tworzysz kontakty i przypisujesz im długi oraz płatności. Kontakty można utworzyć wewnętrznie albo zaimportować z książki adresowej urządzenia, dla wygody. Mój use case to dokładnie jednorazowe wykonanie „pobierz kontakty z książki adresowej urządzenia do aplikacji”.

Selektor systemowy sprawdza się u mnie doskonale, nie potrzebuję bezterminowego, powtarzalnego dostępu do książki adresowej; potrzebuję tylko doraźnego dostępu w określonym momencie, żeby pobrać kontakty i zapisać je w aplikacji.

⬆️ Selektor, który widzisz na tym filmie, to nowy natywny selektor Androida 17.

Przypadki, które nadal uzasadniają uprawnienie, to te wymagające pełnej książki adresowej w sposób powtarzalny, a nie jednorazowego wyboru: aplikacje telefonu albo komunikatory, identyfikacja rozmówcy, czy kopie zapasowe i synchronizacja kontaktów. Jeśli twoja aplikacja nie należy do tej kategorii, selektor najpewniej ci wystarczy.

Implementacja Contact Pickera z Androida 17 we Flutterze

Weźmy za przykład prostą aplikację testową, która po prostu wypisuje kontakty z książki adresowej:

Lista kontaktów w przykładowej aplikacji

Jeśli chcemy pobrać kontakty w „klasyczny” sposób, możemy użyć wtyczki takiej jak flutter_contacts:

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

Ten kod da nam listę kontaktów z nazwą i zdjęciem. Trzeba jednak najpierw poprosić użytkownika o uprawnienie, bo inaczej wywołanie nic nie zwróci:

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

Teraz zróbmy zmiany potrzebne do dostosowania tego do Androida 17+. Przede wszystkim zadeklarujmy uprawnienie READ_CONTACTS tylko dla urządzeń z Androidem 16 lub starszym:

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

Musimy też kompilować z SDK Androida 17, bo klasa ContactsPickerSessionContract nie istnieje we wcześniejszych wersjach. Flutter domyślnie używa jeszcze 36, więc trzeba to ustawić ręcznie w build.gradle.kts aplikacji:

android {
    compileSdk = 37

    defaultConfig {
        targetSdk = 37
    }
}

Uruchomienie selektora z Kotlina przez ACTION_PICK_CONTACTS

Moglibyśmy użyć do tego jakiejś wtyczki, ale skoro implementacja jest dość prosta, wolę natywne rozwiązanie, żeby nie uzależniać się od kolejnej wtyczki. Strona natywna i strona Dart rozmawiają ze sobą przez method channel, a to z Kotlina wywołujemy selektor systemowy, konfigurując go do wyboru wielu kontaktów w taki sposób:

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)

To call.arguments as Int to limit zaznaczenia, który dociera do nas z Darta przez method channel.

Kiedy użytkownik skończy, selektor nie zwraca nam kontaktów bezpośrednio, tylko session URI wskazujące na zaznaczenie. Trzeba je odpytać przez ContentResolver i tu jest ważny szczegół: kursor daje nam jeden wiersz na każde żądane pole, a nie jeden na kontakt, więc trzeba pogrupować wiersze:

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

Pamiętaj, że to zapytanie to I/O, więc warto zdjąć je z głównego wątku, zanim odpowiemy Dartowi.

Tym kodem pobierzemy kontakty z nazwą i zdjęciem, nic więcej nie jest nam potrzebne do powyższego przykładu.

Odczyt wybranych kontaktów w Darcie

Następnie w naszym kodzie Dart zdefiniujmy metodę, która powie nam, czy mamy użyć selektora systemowego, czy klasycznej wtyczki. Do tego musimy znać aktualną wersję Androida, a możemy to zrobić wtyczką 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;
}

Mając to, możemy już pobrać kontakty właściwym mechanizmem:

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

  return await _readContactsWithPermission();
}

Na Androidzie 17+ pobieramy kontakty ze strony natywnej i je parsujemy:

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

I jeszcze jedno, o czym warto pamiętać: selektor otwiera użytkownik, a nie aplikacja. Nie uruchomisz go na własną rękę, żeby odświeżyć kontakty w tle, więc jeśli zaznaczenie przyda ci się później, zapisz je.

Pokazałem ci tu tylko kluczowe fragmenty, ale żeby lepiej zrozumieć całość, możesz zobaczyć pełny projekt tutaj.

Podsumowanie

W tym artykule bardzo szybko przejrzeliśmy nowe zasady Google Play dotyczące uprawnienia do odczytu kontaktów i pokazałem ci proste podejście, które sam stosuję, żeby pracować z kontaktami w swoich aplikacjach.

Jeśli uznasz, że uprawnienie do kontaktów jest ci absolutnie niezbędne, pamiętaj, że musisz wypełnić dedykowany formularz w Google Play, zanim zasady wejdą w życie 27 stycznia 2027.

Do następnego razu!

Udostępnij :