LoginSignup
3
1

More than 5 years have passed since last update.

こんにちは。

何をしようと思い立ったのか

Androidプリインストールアプリ「Contacts」にContentResolverでアクセス」という記事を投稿しましたが、この記事では自作Androidアプリから、プリインストールされているContactsという連絡先アプリの持つデータを取得(検索query読み込みREAD)しかしていませんでした。マニフェストにも、<uses-permission android:name="android.permission.READ_CONTACTS" />しか書いてませんでしたし。

そこで今回は、連絡先アプリにデータの登録(挿入insert書き込みWRITE)をチャレンジしてみようと思い立ったのです。

趣旨、が途中から変わってきた

ところが。

Android 6.0(API Level 23・Marshmallow)以降、パーミッションPermissionの仕様が変更されました。Android Developersサイトの「システム パーミッション」の特に「Normal パーミッションと Dangerous パーミッション」にご注目です。

アプリが Dangerous パーミッションが必要性であると宣言している場合、ユーザーはアプリに明示的にパーミッションを付与しなければなりません。

Marshmallowより以前では、アプリをインストールする際に「このアプリケーションに許可する権限」と一覧表示されて、ユーザにインストールするか否かの選択を迫ってきました。
しかし、Marshmallow以降の端末では、インストールはサクッとしちゃって、いざそのアプリでパーミッションを使わなければならない段になった際にユーザに以下の画面のように問うてくるようになりました。

Screenshot_1518139161.png

私は当初、「んじゃあ、こういうDialog(がAPIに在るんだろうから)を作ってnewしてshowしてOnClickListenerを実装して...」ということを推測したのですが、真相はさにあらず。このダイアログ、作らない(定義しない)のです。

などなど、当初は単に『連絡先アプリに書き込みたい』だけが目的だったのですが、やおらMarshmallow未満と以降のパーミッションの扱いについて調べる、という風向きというか趣旨が変わってきちゃって、の顛末をここに記します。

結論

Marshmallow以降では、以下のようなプログラムの流れでパーミッションの要求・許可・利用がなされます。

  • マニフェストには、READ_CONTACTSとWRITE_CONTACTSの両方を書く
  • ContextCompat#checkSelfPermissionで、パーミッションがアプリに付与されているか確認する
  • ActivityCompat#requestPermissionsで、READ_CONTACTSパーミッションを要求する(ユーザに許可を求めるダイアログを表示する)
    • あら?WRITE_CONTACTSパーミッションの要求はしなくてもイイの?
      • しなくても、イイんです。その2つのパーミッションは、同じ「パーミッション グループ」に所属しているので、片方だけ許可すればOK
  • ActivityCompat.OnRequestPermissionsResultCallback#onRequestPermissionsResultは、ユーザ選択の結果を受けるコールバックメソッドだ

なお、Marshmallow未満の場合のプログラムの挙動についてもあとで記します。

環境

Android Studio 3.0.1
Build #AI-171.4443003, built on November 10, 2017
JRE: 1.8.0_152-release-915-b01 amd64
JVM: OpenJDK 64-Bit Server VM by JetBrains s.r.o
Windows 10 10.0

使用言語はJavaオンリー。Kotlin使いません。

作ってみたアプリ(をMarshmallowのエミュレータ上で動作確認)

開発さんのように、こんなアプリ開発してみた。ブンブン、ハローQiita。

なお、事前に連絡先アプリに2件登録しておきます。

Screenshot_1518139084.png

作ったアプリの初期画面では、ボタンがあります。Marshmallowのエミュレータ上で動作確認しました。

Screenshot_1518139112.png

アプリ名は気になさらないでください。いろいろ趣旨から外れだして、Normal/Dangerousパーミッションのことを調べていたら、こんな名前のアプリにしちゃっていました。

ボタンを押します。

Screenshot_1518139161.png

パーミッションの許可をユーザに求めるダイアログが出ます。1回試しに「許可しない」と天邪鬼ってみます。

Screenshot_1518139169.png

そんな天邪鬼:japanese_ogre:には、トーストを出してやりましょう。

ごめんなさい、改心しました。:angel:もう一度「連絡先を読みこむ」のボタンをクリックします。

Screenshot_1518139178.png

今度こそ、「許可」のボタンをクリックします。

Screenshot_1518139185.png

3件出ます。そのココロは、徳川家康を登録したうえで全件検索したからです。

念のため、今一度、連絡先アプリを見てみます。

Screenshot_1518139193.png

私のアプリのせいで、やっぱり徳川家康は登録されていました。

プログラム

初期画面

MainActivity.java
package jp.co.casareal.normaldangerouspermissions;

import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.View;
import android.widget.Toast;

/**
 * 連絡先アプリの持つデータを読み込むパーミッションが付与されている判定する画面
 */
public class MainActivity extends AppCompatActivity {

    // この定数は要件に応じて用意する
    private static final int REQUEST_CODE_READ_CONTACTS = 123;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        View button = findViewById(R.id.button);
        button.setOnClickListener(new OnClickReadContactsListener());
    }

    /**
     * クリックイベントのリスナー
     */
    private class OnClickReadContactsListener implements View.OnClickListener {
        @Override
        public void onClick(View v) {
            // パーミッションがアプリに付与されているか確認する
            if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
                Log.v("MainActivity", "API Level = " + Build.VERSION.SDK_INT + ": パーミッションが付与されていない");
                // パーミッションが付与されていない場合、
                // パーミッションを要求する(ユーザに許可を求めるダイアログを表示する)
                ActivityCompat.requestPermissions(MainActivity.this, new String[]{Manifest.permission.READ_CONTACTS}, REQUEST_CODE_READ_CONTACTS);
            } else {
                // API Level 23未満(Marshmallow)は、問答無用でこのelseブロックになります
                Log.v("MainActivity", "API Level = " + Build.VERSION.SDK_INT + ": パーミッションが付与されている");
                // パーミッションが付与されているので、画面遷移します
                transitionContactList();
            }
        }
    }

    /**
     * ユーザ選択の結果を受けるコールバックメソッド
     */
    @Override
    public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        Log.v("MainActivity", "API Level = " + Build.VERSION.SDK_INT + ": requestCode = " + requestCode);
        switch (requestCode) {
            case REQUEST_CODE_READ_CONTACTS:
                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    // パーミッションの利用が許可されている
                    transitionContactList();
                } else {
                    // パーミッションの利用が許可されていない
                    // 今回はトーストを出してお茶を濁す程度
                    Toast.makeText(this, String.format("連絡先アプリの利用が許可されていません"), Toast.LENGTH_LONG).show();
                }
                return;
        }
    }

    /**
     * 連絡先一覧を表示する画面へ遷移する
     */
    private void transitionContactList() {
        Intent intent = new Intent(getApplicationContext(), ContactAccessActivity.class);
        startActivity(intent);
    }
}

初期画面(MainActivity.java)のポイント

private static final int REQUEST_CODE_READ_CONTACTS = 123;のように、リクエストコードを任意のint値で用意しておきます。

ContextCompat#checkSelfPermissionメソッドで、パーミッションがアプリに付与されているか確認します。お目当てのパーミッションは第2引数で指定します。
この時if文で評価し、偽の場合、ユーザに許可を求めるダイアログを表示するべくの、ActivityCompat#requestPermissionsメソッドを呼び出しています。

このActivityCompat#requestPermissionsメソッドの第2引数と第3引数が、コールバックメソッドであるonRequestPermissionsResultメソッドの第1引数と第2引数となります。

というわけで、onRequestPermissionsResultメソッドをオーバーライドし、ifやswitchなどでパーミッション利用の許可の可否に応じた処理をコーディングします。

ポイントをまとめます。

  • ContextCompat#checkSelfPermissionメソッドで、利用したいパーミッションがアプリに付与されているか確認する
  • 『ユーザに許可を求めるダイアログ』は作らない
    • そのダイアログを出すのは、ActivityCompat#requestPermissionsメソッドだ
  • コールバックメソッドonRequestPermissionsResultをオーバーライドして処理を書く
  • ユーザに許可を求めるパーミッションは、READ_CONTACTSだけだ! :astonished:
    • WRITE_CONTACTSは求めていない! :grimacing:
      • でも大丈夫!WRITE_CONTACTSは、READ_CONTACTSと同じ、Permission Groupに所属しているので! :grin:

Android Developersサイトに、以下のように記載されています。

アプリがマニフェストに記載された Dangerous パーミッションをリクエストし、そのアプリがすでに同じパーミッション グループに属する別の Dangerous パーミッションを付与されている場合は、システムはユーザーに確認することなく、すぐにパーミッションを付与します。たとえば、アプリが以前に READ_CONTACTS パーミッションをリクエストして許可されており、さらにWRITE_CONTACTS をリクエストした場合、システムはパーミッションを即時に付与します。

第2画面(一覧表示画面)

遷移先の画面です。

ContactAccessActivity.java
package jp.co.casareal.normaldangerouspermissions;

import android.app.ListActivity;
import android.content.ContentProviderOperation;
import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.Email;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.CommonDataKinds.StructuredName;
import android.provider.ContactsContract.Contacts.Data;
import android.provider.ContactsContract.RawContacts;
import android.util.Log;
import android.widget.ArrayAdapter;

import java.util.ArrayList;
import java.util.List;

/**
 * 連絡先アプリに、まずは1件登録(WRITE_CONTACTSパーミッション)して、
 * そして全件取得(READ_CONTACTSパーミッション)して表示する画面
 */
public class ContactAccessActivity extends ListActivity {

    // 連絡先アプリのContentProviderにアクセスするべくの、ContentResolver
    private ContentResolver contentResolver;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_contact_list);

        contentResolver = getContentResolver();

        // まずは、1件登録
        addToContact();

        // ContactsContract.Contacts.CONTENT_URIは、標準APIで提供されている連絡先アプリを指すUri型の定数
        Cursor cursor = contentResolver.query(ContactsContract.Contacts.CONTENT_URI, null, null, null, null);
        if (cursor != null) {
            List<String> contactDataList = new ArrayList<>();
            while (cursor.moveToNext()) {
                // ContactsContract.Contacts.DISPLAY_NAMEは、標準APIで提供されているString型の定数(連絡先アプリ上の氏名を指す)
                String name = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME));
                // 今回は、氏名だけを収集
                contactDataList.add(name);
            }
            cursor.close();
            setListAdapter(new ArrayAdapter<String>(this, R.layout.contact_row, contactDataList));
        }
    }

    /**
     * 一件だけなのにContentResolver#applyBatchを使って、連絡先アプリに登録
     */
    private void addToContact() {
        ArrayList<ContentProviderOperation> ops = new ArrayList<>();
        int rawContactInsertIndex = ops.size();

        ops.add(ContentProviderOperation
                .newInsert(RawContacts.CONTENT_URI)
                .withValue(RawContacts.ACCOUNT_TYPE, null)
                .withValue(RawContacts.ACCOUNT_NAME, null)
                .build());

        // 氏名の設定
        ops.add(ContentProviderOperation
                .newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(Data.RAW_CONTACT_ID, rawContactInsertIndex)
                .withValue(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE)
                .withValue(StructuredName.FAMILY_NAME, "徳川")
                .withValue(StructuredName.GIVEN_NAME, "家康")
                .withValue(StructuredName.PHONETIC_FAMILY_NAME, "とくがわ")
                .withValue(StructuredName.PHONETIC_GIVEN_NAME, "いえやす")
                .build());

        // 電話番号の設定
        ops.add(ContentProviderOperation
                .newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(Data.RAW_CONTACT_ID, rawContactInsertIndex)
                .withValue(ContactsContract.Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE)
                .withValue(Phone.NUMBER, "056-109-0183")
                .withValue(Phone.TYPE, Phone.TYPE_MOBILE)
                .build());

        // メールアドレスの設定
        ops.add(ContentProviderOperation
                .newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(Data.RAW_CONTACT_ID, rawContactInsertIndex)
                .withValue(ContactsContract.Data.MIMETYPE, Email.CONTENT_ITEM_TYPE)
                .withValue(Email.DATA, "ieyasu@shogun.edo")
                .withValue(Email.TYPE, Email.TYPE_MOBILE)
                .build());

        try {
            ContentProviderResult[] res = contentResolver.applyBatch(ContactsContract.AUTHORITY, ops);
            Log.v("ContactListActivity", res[0].toString());
        } catch (Exception e) {
            Log.e("ContactListActivity", e.getLocalizedMessage(), e);
            e.printStackTrace();
        }
    }
}

登録の処理がやけに行数が嵩みました。全件検索はCursor cursor = contentResolver.query(ContactsContract.Contacts.CONTENT_URI, null, null, null, null);の一行ですんだのに。

第2画面(ContactAccessActivity.java)のポイント

今回は、徳川家康のたったの一件だけの登録しかしないのに、ContentResolver#applyBatchメソッドを使ったのは大袈裟でした(おかげで行数が嵩みました)が、まあそこはprivateなメソッドで纏めたのでご勘弁ください。

マニフェスト

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="jp.co.casareal.normaldangerouspermissions">

    <!-- アプリで利用するパーミッションを利用宣言する -->
    <!-- 連絡先アプリを読み取るパーミッション (Protection Level: Dangerous) -->
    <uses-permission android:name="android.permission.READ_CONTACTS" />
    <!-- 連絡先アプリに書き込むパーミッション (Protection Level: Dangerous) -->
    <uses-permission android:name="android.permission.WRITE_CONTACTS" />
    <!-- ネット通信するパーミッション (Protection Level: Normal) -->
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity android:name=".ContactAccessActivity"></activity>
    </application>

</manifest>

MainActivity.javaでは、『READ_CONTACTSだけを許可すれば、イチイチWRITE_CONTACTSの許可は取らなくても自動付与される』でしたが、マニフェストでは、ちゃんとREAD_CONTACTSもWRITE_CONTACTSも<uses-permission>に記載しておかなければなりません。なお、INTERNETは今回出番なし、なのですがオマケで書いておきました。

Android 5.1(API Level 22・Lollipop)でも動作確認してみた

同じアプリを🍭Lollipopにインストールして動作確認してみます。

build.gradle
apply plugin: 'com.android.application'

android {
    compileSdkVersion 26
    defaultConfig {
        applicationId "jp.co.casareal.normaldangerouspermissions"
        minSdkVersion 19
        targetSdkVersion 26
        versionCode 1
        versionName "1.0"
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:26.1.0'
}

minSdkVersionを19にしておいたので、Lollipopでも動きます。

Lollipopエミュレータの連絡先アプリにも、2名事前に登録しておきました。けど、面倒くさくなってしまったので、氏は端折りました。

Screenshot_1518139243.png

さて、作ったアプリですが、ボタンを押しても許可を問うダイアログは出ず、いきなり第2画面に遷移します。

Screenshot_1518139270.png

連絡先アプリを見返してみれば、ちゃんと登録されています。

Screenshot_1518139288.png

つまりは、インストールしてある、すなわち、もうとっくに許可済み、ということですね。

一応、アプリ情報も見てみましょう。

Screenshot_1518139320.png

「連絡先の読み取り」「連絡先の変更」が見受けられました。

謝辞

一般社団法人日本スマートフォンセキュリティ協会の『Androidアプリのセキュア設計・セキュアコーディングガイド』とそのサンプルコードが非常に参考になりました。ありがとうございました。

以上です。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1