Contact Picker do Android 17 em Flutter: hora de substituir READ_CONTACTS

Contact Picker do Android 17 em Flutter: hora de substituir READ_CONTACTS

Índice

O Android 17 traz uma mudança importante no que toca à leitura dos contactos do dispositivo: agora é possível invocar um seletor do sistema para escolher contactos e importá-los para a nossa app, sem necessidade de declarar a permissão de leitura READ_CONTACTS no nosso manifesto.

Por causa disso, a Google anunciou novas restrições: a permissão READ_CONTACTS fica reservada para aquelas apps que não conseguem funcionar apenas com o seletor do sistema. Isto implica que, se a tua app importa contactos de forma simples, vais ter de usar o seletor. Se a permissão te for imprescindível para a app funcionar, terás de enviar uma declaração na Play Console a justificar porque é que o seletor não te serve. A política entra em vigor a 27 de janeiro de 2027.

Dito isto, aplica-se às apps que apontem para targetSdk 37 ou superior. Se a tua ainda aponta para uma versão anterior, a política não te afeta para já, embora te acabe por afetar assim que subires o target.

Neste artigo vou tentar dar-te um guia para perceberes o que isto implica, como é que te vai afetar, e se precisas mesmo de continuar a usar a permissão ou se o seletor do sistema já te chega. Depois, vou dar-te alguns exemplos de código para te mostrar como implementei eu próprio o seletor do sistema no Android 17+ nas minhas apps.

Info

📽 Versão em vídeo disponível no YouTube

A nova política de contactos da Google Play afeta a tua app?

A maioria das apps que acedem a contactos vai continuar a funcionar perfeitamente com o seletor do sistema, sem precisar de declarar a permissão READ_CONTACTS.

Se a tua aplicação acede aos contactos de forma pontual, por exemplo para os importar dentro da app, ou talvez para obter um dado específico sobre um contacto num momento determinado, então o seletor do sistema é mais do que suficiente.

A diferença está no alcance: com a permissão lias a agenda inteira, enquanto que com o seletor só recebes os contactos que o utilizador escolhe, e ainda por cima podes pedir apenas os campos que te interessam. O acesso também é temporário, por isso esgota-se assim que acabas de ler a seleção.

Vamos pegar como exemplo na minha aplicação Controle de Dívidas. Uma app onde crias contactos e lhes atribuis dívidas e pagamentos. Os contactos podem ser criados internamente, ou então podes optar por importá-los da agenda do dispositivo, por comodidade. O meu use case é precisamente uma execução pontual de «ir buscar os contactos da agenda do dispositivo para dentro da app».

O seletor do sistema serve-me na perfeição, não preciso de acesso recorrente e sem fim à agenda; só preciso de um acesso pontual, num momento determinado, para trazer os contactos e guardá-los dentro da app.

⬆️ O seletor que vês neste vídeo é o novo seletor nativo do Android 17.

Os casos que continuam mesmo a justificar a permissão são os que precisam da agenda completa de forma recorrente, e não de uma seleção pontual: apps de telefone ou mensagens, identificação de chamadas, ou cópias de segurança e sincronização de contactos. Se a tua app não encaixa em nada disso, o mais provável é que o seletor te chegue.

Implementar o Contact Picker do Android 17 em Flutter

Vamos pegar como exemplo numa app de testes simples que se limita a listar os contactos da agenda:

Lista de contactos na app de exemplo

Se quisermos obter os contactos da forma «convencional», podemos usar um plugin como o flutter_contacts:

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

Este código vai dar-nos uma lista de contactos com o nome e a imagem. No entanto, primeiro é preciso pedir a permissão ao utilizador, ou a chamada não nos devolve nada:

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

Agora vamos fazer as adaptações necessárias para o Android 17+. A primeira coisa é declarar a permissão READ_CONTACTS apenas para dispositivos com Android 16 ou inferior:

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

Também precisamos de compilar contra o SDK do Android 17, porque a classe ContactsPickerSessionContract não existe em versões anteriores. O Flutter ainda usa o 36 por omissão, por isso é preciso fixá-lo à mão no build.gradle.kts da app:

android {
    compileSdk = 37

    defaultConfig {
        targetSdk = 37
    }
}

Lançar o seletor a partir do Kotlin com ACTION_PICK_CONTACTS

Depois, podíamos usar algum plugin para isto, mas dado que o desenvolvimento é bastante simples, eu prefiro uma implementação nativa para evitar depender de mais um plugin. A parte nativa e a parte Dart comunicam através de um method channel, e é a partir do Kotlin que invocamos o seletor do sistema, configurando-o para seleção múltipla da seguinte forma:

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)

Esse call.arguments as Int é o limite de seleção, que nos chega do Dart através do method channel.

Quando o utilizador termina, o seletor não nos devolve os contactos diretamente, mas sim uma session URI que aponta para a seleção. É preciso consultá-la com o ContentResolver, e aqui está o detalhe importante: o cursor dá-nos uma linha por cada campo pedido, e não uma por contacto, por isso há que agrupar as linhas:

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

Tem em conta que esta consulta é I/O, por isso convém tirá-la da thread principal antes de responder ao Dart.

Com este código conseguimos obter os contactos com o seu nome e a sua foto, não precisamos de mais nada para o exemplo anterior.

Ler os contactos selecionados em Dart

Depois, no nosso código Dart, vamos definir um método que nos indica se devemos usar o seletor do sistema ou o plugin tradicional. Para isso, temos de saber a versão do Android atual, e podemos fazê-lo com o 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;
}

Com isto, já podemos obter os contactos pelo mecanismo apropriado:

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

  return await _readContactsWithPermission();
}

No Android 17+, obtemos os contactos do lado nativo e fazemos o parse:

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

Uma última coisa a ter em conta: o seletor é aberto pelo utilizador, não pela app. Não o podes lançar por tua conta para atualizar os contactos em segundo plano, por isso se precisares da seleção mais tarde, guarda-a.

Aqui só te mostrei as partes principais, mas para perceberes melhor o desenvolvimento podes ver o projeto completo aqui.

Conclusão

Neste artigo vimos de forma muito rápida a nova política da Google Play relativamente à permissão de leitura de contactos, e mostrei-te um approach simples que segui eu próprio para trabalhar com contactos nas minhas aplicações.

Caso consideres que precisas mesmo da permissão de contactos, lembra-te de que tens de preencher o formulário dedicado na Google Play antes de a política entrar em vigor, a 27 de janeiro de 2027.

Até à próxima!