Contact Picker de Android 17 en Flutter: hora de sustituir READ_CONTACTS

Contact Picker de Android 17 en Flutter: hora de sustituir READ_CONTACTS

Tabla de contenidos

Android 17 trae un cambio importante con respecto a la lectura de contactos del dispositivo: ahora es posible invocar un selector del sistema para elegir contactos e importarlos a nuestra app sin necesidad de declarar el permiso de lectura READ_CONTACTS en nuestro manifiesto.

Debido a eso, Google ha anunciado nuevas restricciones: el permiso READ_CONTACTS queda relegado a aquellas apps que no puedan funcionar solo con el selector del sistema. Esto implica que, si tu app importa contactos de forma simple, vas a tener que usar el selector. Si el permiso te resulta imprescindible para que la app funcione, tendrás que enviar una declaración en Play Console justificando por qué el selector no te sirve. La política entra en vigor el 27 de enero de 2027.

Eso sí, esto aplica a las apps que apunten a targetSdk 37 o superior. Si la tuya todavía apunta a una versión anterior, la política no te afecta de momento, aunque te acabará afectando en cuanto subas el target.

En este artículo voy a intentar darte una guía para entender qué implica esto, cómo te va a afectar, y si realmente necesitas seguir usando el permiso o con el selector del sistema ya te sirve. Después, te daré algunos ejemplos de código para enseñarte cómo he implementado yo mismo el selector del sistema en Android 17+ en mis propias apps.

Info

📽 Versión en vídeo disponible en YouTube

¿Te afecta la nueva política de contactos de Google Play?

La mayoría de apps que acceden a contactos van a poder seguir funcionando perfectamente con el selector del sistema sin necesidad de declarar el permiso READ_CONTACTS.

Si tu aplicación accede a los contactos de forma puntual, por ejemplo para importarlos dentro de la app, o quizá para obtener un dato específico sobre un contacto en un momento determinado, entonces el selector del sistema es más que suficiente.

La diferencia está en el alcance: con el permiso leías la agenda entera, mientras que con el selector solo recibes los contactos que el usuario elige, y además puedes pedir únicamente los campos que te interesan. El acceso también es temporal, de modo que se agota en cuanto terminas de leer la selección.

Pongamos como ejemplo mi aplicación Control de Deudas. Una app donde creas contactos, y les asignas deudas y pagos. Los contactos se pueden crear de forma interna, o bien puedes elegir importarlos de la agenda del dispositivo para mayor comodidad. Mi use case es precisamente una ejecución puntual de “Obtener los contactos de la agenda del dispositivo dentro de la app”.

El selector del sistema me sirve perfectamente, no necesito acceso recurrente sin fin a la agenda; sólo necesito un acceso puntual en un momento determinado para traerme los contactos y guardármelos dentro de la app.

⬆️ El selector que ves en este vídeo es el nuevo selector nativo de Android 17.

Los casos que sí siguen justificando el permiso son los que necesitan la agenda completa de forma recurrente, y no una selección puntual: apps de teléfono o mensajería, identificación de llamadas, o copias de seguridad y sincronización de contactos. Si tu app no encaja en algo así, lo más probable es que el selector te valga.

Implementar el Contact Picker de Android 17 en Flutter

Pongamos como ejemplo una app de pruebas sencilla donde simplemente listamos los contactos de la agenda:

Listado de contactos de la app de ejemplo

Si queremos obtener los contactos de forma “convencional”, podemos utilizar un plugin como flutter_contacts:

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

Este código nos dará un listado de contactos con nombre e imagen. Eso sí, antes hay que pedirle el permiso al usuario, o la llamada no nos devolverá nada:

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

Ahora realicemos los cambios necesarios para adaptarlo a Android 17+. Lo primero de todo es declarar el permiso de READ_CONTACTS únicamente para dispositivos con Android 16 o inferior:

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

También necesitamos compilar contra el SDK de Android 17, porque la clase ContactsPickerSessionContract no existe en versiones anteriores. Flutter todavía usa el 36 por defecto, así que hay que fijarlo a mano en el build.gradle.kts de la app:

android {
    compileSdk = 37

    defaultConfig {
        targetSdk = 37
    }
}

Lanzar el selector desde Kotlin con ACTION_PICK_CONTACTS

Luego, podríamos utilizar algún plugin para esto, pero dado que el desarrollo es bastante sencillo, yo prefiero una implementación nativa para evitarme depender de otro plugin. La parte nativa y la parte Dart se comunican con un method channel, y es desde Kotlin donde invocamos el selector del sistema, configurándolo para selección múltiple de la siguiente manera:

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)

Ese call.arguments as Int es el límite de selección, que nos llega desde Dart a través del method channel.

Cuando el usuario termina, el selector no nos devuelve los contactos directamente, sino una session URI que apunta a la selección. Hay que consultarla con el ContentResolver, y aquí está el detalle importante: el cursor nos da una fila por cada campo solicitado, no una por contacto, así que toca agrupar las filas:

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

Ten en cuenta que esa consulta es I/O, así que conviene sacarla del hilo principal antes de responderle a Dart.

Con este código podemos obtener los contactos con su nombre y su foto, no nos hace falta nada más para el ejemplo anterior.

Leer los contactos seleccionados en Dart

Luego, en nuestro código Dart, definamos un método que nos indicará si debemos utilizar el selector del sistema o el plugin tradicional. Para ello, debemos conocer la versión de Android actual, cosa que podemos hacer con el 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 esto, ya podemos proceder a obtener los contactos con el mecanismo apropiado:

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

  return await _readContactsWithPermission();
}

En Android 17+, obtenemos los contactos del lado nativo y los parseamos:

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

Una última cosa a tener en cuenta: el selector lo abre el usuario, no la app. No puedes lanzarlo por tu cuenta para refrescar los contactos en segundo plano, así que si necesitas la selección más adelante, guárdatela.

Aquí sólo te he mostrado las partes clave, pero para entender mejor el desarrollo puedes ver el proyecto completo aquí.

Conclusión

En este artículo hemos visto de forma muy rápida la nueva política de Google Play con respecto al permiso de lectura de contactos, y te he mostrado un approach simple que he seguido yo para trabajar con contactos en mis aplicaciones.

En el caso de que consideres que necesitas sí o sí el permiso de contactos, recuerda que debes cumplimentar el formulario dedicado a ello en Google Play antes de que la política entre en vigor, el 27 de enero de 2027.

¡Hasta la próxima!