Contact Picker di Android 17 in Flutter: è ora di sostituire READ_CONTACTS

Contact Picker di Android 17 in Flutter: è ora di sostituire READ_CONTACTS

Indice dei contenuti

Android 17 porta un cambiamento importante per quanto riguarda la lettura dei contatti del dispositivo: ora è possibile invocare un selettore di sistema per scegliere i contatti e importarli nella nostra app, senza bisogno di dichiarare il permesso di lettura READ_CONTACTS nel nostro manifest.

Per questo motivo, Google ha annunciato nuove restrizioni: il permesso READ_CONTACTS resta riservato a quelle app che non possono funzionare con il solo selettore di sistema. Il che significa che, se la tua app importa contatti in modo semplice, dovrai usare il selettore. Se il permesso ti è indispensabile perché l’app funzioni, dovrai inviare una dichiarazione nella Play Console spiegando perché il selettore non ti basta. La policy entra in vigore il 27 gennaio 2027.

Detto questo, vale per le app che puntano a targetSdk 37 o superiore. Se la tua punta ancora a una versione precedente, la policy non ti riguarda per ora, anche se ti raggiungerà non appena alzerai il target.

In questo articolo cercherò di darti una guida per capire cosa comporta tutto questo, come ti riguarda, e se hai davvero bisogno di continuare a usare il permesso oppure il selettore di sistema ti basta. Dopo, ti darò qualche esempio di codice per mostrarti come ho implementato io stesso il selettore di sistema su Android 17+ nelle mie app.

Info

📽 Versione video disponibile su YouTube

La nuova policy sui contatti di Google Play riguarda la tua app?

La maggior parte delle app che accedono ai contatti continuerà a funzionare benissimo con il selettore di sistema, senza bisogno di dichiarare il permesso READ_CONTACTS.

Se la tua applicazione accede ai contatti in modo puntuale, per esempio per importarli dentro l’app, o magari per ottenere un dato specifico su un contatto in un dato momento, allora il selettore di sistema è più che sufficiente.

La differenza sta nella portata: con il permesso leggevi l’intera rubrica, mentre con il selettore ricevi solo i contatti che l’utente sceglie, e per di più puoi chiedere soltanto i campi che ti interessano. Anche l’accesso è temporaneo, quindi si esaurisce non appena hai finito di leggere la selezione.

Prendiamo come esempio la mia applicazione Controllo del Debito. Un’app dove crei contatti e gli assegni debiti e pagamenti. I contatti si possono creare internamente, oppure puoi scegliere di importarli dalla rubrica del dispositivo, per comodità. Il mio use case è proprio un’esecuzione puntuale di «prendere i contatti dalla rubrica del dispositivo e portarli dentro l’app».

Il selettore di sistema mi va benissimo, non mi serve un accesso ricorrente e senza fine alla rubrica; mi serve solo un accesso puntuale, in un dato momento, per portarmi dentro i contatti e salvarmeli nell’app.

⬆️ Il selettore che vedi in questo video è il nuovo selettore nativo di Android 17.

I casi che giustificano ancora il permesso sono quelli che hanno bisogno della rubrica completa in modo ricorrente, e non di una selezione puntuale: app di telefonia o messaggistica, identificazione delle chiamate, oppure backup e sincronizzazione dei contatti. Se la tua app non rientra in qualcosa del genere, è molto probabile che il selettore ti basti.

Implementare il Contact Picker di Android 17 in Flutter

Prendiamo come esempio una semplice app di prova che si limita a elencare i contatti della rubrica:

Elenco dei contatti nell'app di esempio

Se vogliamo ottenere i contatti nel modo «convenzionale», possiamo usare un plugin come flutter_contacts:

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

Questo codice ci darà un elenco di contatti con nome e immagine. Detto questo, prima bisogna chiedere il permesso all’utente, altrimenti la chiamata non ci restituirà nulla:

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

Ora facciamo le modifiche necessarie per adattarlo ad Android 17+. La prima cosa è dichiarare il permesso READ_CONTACTS solo per i dispositivi con Android 16 o inferiore:

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

Dobbiamo anche compilare con l’SDK di Android 17, perché la classe ContactsPickerSessionContract non esiste nelle versioni precedenti. Flutter usa ancora il 36 di default, quindi va impostato a mano nel build.gradle.kts dell’app:

android {
    compileSdk = 37

    defaultConfig {
        targetSdk = 37
    }
}

Lanciare il selettore da Kotlin con ACTION_PICK_CONTACTS

Poi, potremmo usare qualche plugin per questo, ma dato che lo sviluppo è abbastanza semplice, io preferisco un’implementazione nativa per evitare di dipendere dall’ennesimo plugin. La parte nativa e la parte Dart comunicano tramite un method channel, ed è da Kotlin che invochiamo il selettore di sistema, configurandolo per la selezione multipla in questo modo:

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)

Quel call.arguments as Int è il limite di selezione, che ci arriva da Dart attraverso il method channel.

Quando l’utente ha finito, il selettore non ci restituisce direttamente i contatti, ma una session URI che punta alla selezione. Va interrogata con il ContentResolver, ed ecco il dettaglio importante: il cursore ci dà una riga per ogni campo richiesto, non una per contatto, quindi tocca raggruppare le righe:

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

Tieni presente che questa query è I/O, quindi conviene toglierla dal thread principale prima di rispondere a Dart.

Con questo codice possiamo ottenere i contatti con il loro nome e la loro foto, non ci serve altro per l’esempio di prima.

Leggere i contatti selezionati in Dart

Poi, nel nostro codice Dart, definiamo un metodo che ci dirà se dobbiamo usare il selettore di sistema o il plugin tradizionale. Per farlo dobbiamo conoscere la versione di Android attuale, e possiamo farlo con il 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;
}

Con questo possiamo finalmente ottenere i contatti con il meccanismo giusto:

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

  return await _readContactsWithPermission();
}

Su Android 17+, otteniamo i contatti dal lato nativo e li parsiamo:

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

Un’ultima cosa da tenere a mente: il selettore lo apre l’utente, non l’app. Non puoi lanciarlo per conto tuo per aggiornare i contatti in background, quindi se ti serve la selezione più avanti, salvatela.

Qui ti ho mostrato solo le parti chiave, ma per capire meglio lo sviluppo puoi vedere il progetto completo qui.

Conclusione

In questo articolo abbiamo visto molto rapidamente la nuova policy di Google Play riguardo al permesso di lettura dei contatti, e ti ho mostrato un approach semplice che ho seguito io per lavorare con i contatti nelle mie applicazioni.

Nel caso ritenessi di aver assolutamente bisogno del permesso sui contatti, ricorda che devi compilare il modulo dedicato su Google Play prima che la policy entri in vigore, il 27 gennaio 2027.

Alla prossima!