Android 17 Contact Picker in Flutter: tijd om READ_CONTACTS te vervangen

Android 17 Contact Picker in Flutter: tijd om READ_CONTACTS te vervangen

Inhoudsopgave

Android 17 brengt een belangrijke verandering in hoe we de contacten op het toestel uitlezen: je kunt nu een systeemkiezer aanroepen om contacten te kiezen en ze in onze app te importeren, zonder de leesrechten READ_CONTACTS in ons manifest te hoeven declareren.

Daarom heeft Google nieuwe beperkingen aangekondigd: de READ_CONTACTS-permissie is voortaan voorbehouden aan apps die niet kunnen functioneren met alleen de systeemkiezer. Dat betekent dat je de kiezer zult moeten gebruiken als je app contacten op een eenvoudige manier importeert. Is de permissie onmisbaar om je app te laten werken, dan moet je een verklaring in de Play Console indienen waarin je uitlegt waarom de kiezer niet volstaat. Het beleid gaat in op 27 januari 2027.

Dat gezegd hebbende: dit geldt voor apps die op targetSdk 37 of hoger mikken. Mikt de jouwe nog op een eerdere versie, dan raakt het beleid je voorlopig niet, al haalt het je in zodra je het target omhooggooit.

In dit artikel probeer ik je een leidraad te geven om te begrijpen wat dit inhoudt, hoe het je gaat raken, en of je de permissie echt moet blijven gebruiken of dat de systeemkiezer je al volstaat. Daarna geef ik je een paar codevoorbeelden om te laten zien hoe ik de systeemkiezer op Android 17+ zelf in mijn eigen apps heb geïmplementeerd.

Info

📽 Videoversie beschikbaar op YouTube

Raakt het nieuwe contactenbeleid van Google Play jouw app?

De meeste apps die contacten benaderen, blijven prima werken met de systeemkiezer, zonder dat ze de READ_CONTACTS-permissie hoeven te declareren.

Benadert je applicatie de contacten incidenteel, bijvoorbeeld om ze in de app te importeren, of misschien om op een bepaald moment één specifiek gegeven van een contact op te halen, dan is de systeemkiezer meer dan genoeg.

Het verschil zit in de reikwijdte: met de permissie las je het hele adresboek, terwijl je met de kiezer alleen de contacten krijgt die de gebruiker kiest — en bovendien kun je uitsluitend de velden opvragen die je interesseren. De toegang is ook tijdelijk en verloopt zodra je de selectie hebt uitgelezen.

Neem als voorbeeld mijn app Schuldentracker. Een app waarin je contacten aanmaakt en er schulden en betalingen aan toewijst. Contacten kun je intern aanmaken, of je kiest ervoor ze voor het gemak uit het adresboek van het toestel te importeren. Mijn use case is precies een eenmalige uitvoering van ‘de contacten uit het adresboek van het toestel de app in halen’.

De systeemkiezer voldoet voor mij prima; ik heb geen eindeloze, terugkerende toegang tot het adresboek nodig. Ik heb alleen incidentele toegang nodig, op een bepaald moment, om de contacten op te halen en in de app op te slaan.

⬆️ De kiezer die je in deze video ziet, is de nieuwe native kiezer van Android 17.

De gevallen die de permissie wél blijven rechtvaardigen, zijn die waarin je het volledige adresboek terugkerend nodig hebt in plaats van een eenmalige selectie: telefoon- of berichtenapps, nummerherkenning, of back-up en synchronisatie van contacten. Valt je app daar niet onder, dan volstaat de kiezer hoogstwaarschijnlijk.

De Android 17 Contact Picker implementeren in Flutter

Neem als voorbeeld een eenvoudige testapp die enkel de contacten uit het adresboek opsomt:

Contactenlijst in de voorbeeldapp

Willen we de contacten op de ‘conventionele’ manier ophalen, dan kunnen we een plugin als flutter_contacts gebruiken:

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

Deze code geeft ons een lijst met contacten inclusief naam en afbeelding. Je moet de gebruiker wel eerst om toestemming vragen, anders geeft de aanroep niets terug:

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

Laten we nu de aanpassingen doen die nodig zijn voor Android 17+. Het eerste is de READ_CONTACTS-permissie alleen nog declareren voor toestellen met Android 16 of lager:

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

We moeten ook tegen de Android 17-SDK compileren, want de klasse ContactsPickerSessionContract bestaat niet in eerdere versies. Flutter gebruikt standaard nog 36, dus we moeten het met de hand instellen in de build.gradle.kts van de app:

android {
    compileSdk = 37

    defaultConfig {
        targetSdk = 37
    }
}

De kiezer vanuit Kotlin starten met ACTION_PICK_CONTACTS

We zouden hier een plugin voor kunnen gebruiken, maar omdat het nogal eenvoudig te bouwen is, geef ik de voorkeur aan een native implementatie om niet van alweer een plugin afhankelijk te zijn. De native kant en de Dart-kant praten met elkaar via een method channel, en vanuit Kotlin roepen we de systeemkiezer aan en zetten we hem op meervoudige selectie:

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)

Die call.arguments as Int is de selectielimiet, die ons vanuit Dart via het method channel bereikt.

Als de gebruiker klaar is, geeft de kiezer ons de contacten niet rechtstreeks terug, maar een session-URI die naar de selectie wijst. Die moeten we met de ContentResolver bevragen, en hier zit het belangrijke detail: de cursor geeft ons één rij per opgevraagd veld, niet één per contact, dus we moeten de rijen groeperen:

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

Houd er rekening mee dat deze query I/O is, dus haal hem van de main thread af voordat je Dart antwoordt.

Met deze code krijgen we de contacten met hun naam en hun foto; meer hebben we voor het voorbeeld hierboven niet nodig.

De geselecteerde contacten uitlezen in Dart

Vervolgens definiëren we in onze Dart-code een methode die ons vertelt of we de systeemkiezer of de klassieke plugin moeten gebruiken. Daarvoor moeten we de huidige Android-versie weten, wat kan met de 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;
}

Daarmee kunnen we de contacten nu via het juiste mechanisme ophalen:

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

  return await _readContactsWithPermission();
}

Op Android 17+ halen we de contacten op van de native kant en parsen we ze:

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

Nog één ding om in gedachten te houden: de kiezer wordt geopend door de gebruiker, niet door de app. Je kunt hem niet op eigen houtje starten om de contacten op de achtergrond te verversen, dus heb je de selectie later nodig, sla hem dan op.

Ik heb je hier alleen de belangrijkste stukken laten zien, maar om de uitwerking beter te begrijpen kun je het volledige project hier bekijken.

Conclusie

In dit artikel hebben we heel snel het nieuwe beleid van Google Play rond de leesrechten voor contacten doorgenomen, en heb ik je een simpele aanpak laten zien die ik zelf volg om in mijn apps met contacten te werken.

Mocht je vinden dat je de contactenpermissie echt niet kunt missen, vergeet dan niet dat je het daarvoor bestemde formulier bij Google Play moet invullen voordat het beleid op 27 januari 2027 ingaat.

Tot de volgende keer!