FlutterでAndroid 17のContact Picker:READ_CONTACTSを置き換えるとき

FlutterでAndroid 17のContact Picker:READ_CONTACTSを置き換えるとき

目次

Android 17では、端末の連絡先の読み取りに大きな変更が入りました。マニフェストにREAD_CONTACTSの読み取り権限を宣言しなくても、システムのピッカーを呼び出して連絡先を選び、アプリに取り込めるようになっています。

これを受けて、Googleは新しい制限を発表しました。READ_CONTACTS権限は、システムのピッカーだけでは成り立たないアプリのためのものになります。つまり、アプリが単純に連絡先を取り込むだけなら、ピッカーを使うことになります。アプリの動作にどうしても権限が欠かせない場合は、なぜピッカーでは足りないのかを説明するPlay Consoleでの申告を提出する必要があります。このポリシーは2027年1月27日に施行されます。

ただし、これはtargetSdk 37以上をターゲットにしたアプリの話です。まだそれより前のバージョンをターゲットにしているなら、今のところポリシーの影響はありません。とはいえ、ターゲットを上げた時点で避けられなくなります。

この記事では、これが何を意味するのか、どう影響してくるのか、そして本当に権限を使い続ける必要があるのか、それともシステムのピッカーで十分なのかを整理していきます。そのあとで、Android 17+のシステムピッカーを自分のアプリでどう実装したか、コード例をいくつか紹介します。

Info

📽 動画版はYouTubeで公開中

Google Playの新しい連絡先ポリシーは自分のアプリに影響する?

連絡先にアクセスするアプリの大半は、READ_CONTACTS権限を宣言しなくても、システムのピッカーで問題なく動き続けられます。

アプリが連絡先に単発でアクセスするだけなら——たとえばアプリ内に取り込むため、あるいはある時点で特定の情報を1件取得するためなら——システムのピッカーで十分すぎるほどです。

違いはアクセス範囲です。権限では電話帳全体を読んでいましたが、ピッカーではユーザーが選んだ連絡先だけが渡されます。しかも、必要なフィールドだけを指定して要求できます。アクセスは一時的なもので、選択内容を読み終えた時点で失効します。

例として、私のアプリ債務管理を取り上げます。連絡先を作成し、そこに借金や支払いを紐づけるアプリです。連絡先はアプリ内で作ることもできますし、手間を省くために端末の電話帳から取り込むこともできます。私のユースケースはまさに「端末の電話帳から連絡先をアプリに取り込む」という単発の処理です。

システムのピッカーで十分です。電話帳への終わりのない継続的なアクセスは必要なく、ある時点で連絡先を取り込んでアプリ内に保存するための、単発のアクセスさえあれば足ります。

⬆️ この動画に映っているピッカーが、Android 17の新しいネイティブのピッカーです。

権限が今も正当化されるのは、単発の選択ではなく、電話帳全体を継続的に必要とするケースです。電話やメッセージのアプリ、着信者番号の識別、連絡先のバックアップや同期などが該当します。自分のアプリがそうした類でないなら、まずピッカーで事足りるはずです。

FlutterでAndroid 17のContact Pickerを実装する

例として、電話帳の連絡先を一覧表示するだけの簡単なテストアプリを用意します。

サンプルアプリの連絡先一覧

連絡先を「従来どおり」取得したいなら、flutter_contactsのようなプラグインを使えます。

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

このコードで、名前と画像つきの連絡先一覧が得られます。ただし、先にユーザーへ権限を求めておかないと、呼び出しは何も返してくれません。

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

では、Android 17+向けに必要な対応を入れていきます。まず、READ_CONTACTS権限をAndroid 16以下の端末に限定して宣言します。

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

さらに、Android 17のSDKでコンパイルする必要があります。ContactsPickerSessionContractクラスはそれ以前のバージョンには存在しないからです。Flutterはまだ既定で36を使うので、アプリのbuild.gradle.ktsで手動で指定します。

android {
    compileSdk = 37

    defaultConfig {
        targetSdk = 37
    }
}

KotlinからACTION_PICK_CONTACTSでピッカーを起動する

ここでプラグインを使う手もありますが、実装自体はかなり素直なので、プラグインをもう一つ抱え込まずに済むネイティブ実装のほうを私は選びます。ネイティブ側とDart側はmethod channelでやり取りし、システムのピッカーはKotlinから呼び出して、複数選択できるよう次のように設定します。

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)

このcall.arguments as Intが選択上限で、method channel経由でDartから渡ってきます。

ユーザーが選び終えても、ピッカーは連絡先そのものを返してはくれません。返ってくるのは選択内容を指すsession URIです。これをContentResolverで問い合わせるのですが、ここが肝心なところで、カーソルは連絡先ごとではなく要求したフィールドごとに1行を返します。つまり、行をまとめる処理が要ります。

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

このクエリはI/Oなので、Dartに応答する前にメインスレッドから外しておくのが無難です。

このコードで、名前と写真つきの連絡先が取得できます。先ほどの例にはこれ以上は要りません。

選ばれた連絡先をDartで読み取る

続いてDart側で、システムのピッカーと従来のプラグインのどちらを使うべきかを判定するメソッドを定義します。そのためには現在のAndroidバージョンを知る必要があり、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;
}

これで、適切な仕組みを選んで連絡先を取得できるようになりました。

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

  return await _readContactsWithPermission();
}

Android 17+では、ネイティブ側から連絡先を受け取ってパースします。

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

最後にもう一点。ピッカーを開くのはユーザーであって、アプリではありません。バックグラウンドで連絡先を更新しようと自分から起動することはできないので、選択内容をあとで使うなら保存しておいてください。

ここで紹介したのは要点だけですが、実装全体を把握したい場合はプロジェクト一式をこちらで見られます。

まとめ

この記事では、連絡先の読み取り権限に関するGoogle Playの新しいポリシーをざっと押さえたうえで、自分のアプリで連絡先を扱うために実際に取っているシンプルな進め方を紹介しました。

どうしても連絡先の権限が必要だと判断した場合は、ポリシーが施行される2027年1月27日より前に、Google Playで専用のフォームを提出する必要があることをお忘れなく。

それではまた次回!