Android Marshmallow パーミッション

こんにちは。

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

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

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

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

ところが。

Android 6.0(API Level 23・Marshmallow)以降、<ruby>パーミッション<rt>Permission</rt></ruby>の仕様が変更されました。Android Developersサイトの[「システム パーミッション」の特に「Normal パーミッションと Dangerous パーミッション」](https://developer.android.com/guide/topics/security/permissions.html?hl=ja#normal-dangerous)にご注目です。

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

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

![Screenshot_1518139161.png](https://qiita-image-store.s3.amazonaws.com/0/100612/2aa964a6-1b53-e6fc-17d7-650e6a8b061d.png)

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

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

# 結論

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

- マニフェストには、READ_CONTACTSとWRITE_CONTACTSの両方を書く
- [ContextCompat#checkSelfPermission](https://developer.android.com/reference/android/support/v4/content/ContextCompat.html#checkSelfPermission(android.content.Context, java.lang.String))で、パーミッションがアプリに付与されているか確認する
- [ActivityCompat#requestPermissions](https://developer.android.com/reference/android/support/v4/app/ActivityCompat.html#requestPermissions(android.app.Activity, java.lang.String[], int))で、READ_CONTACTSパーミッションを要求する（ユーザに許可を求めるダイアログを表示する）
    - あら？WRITE_CONTACTSパーミッションの要求はしなくてもイイの？
        - しなくても、イイんです。その2つのパーミッションは、同じ「パーミッション グループ」に所属しているので、片方だけ許可すればOK
- [ActivityCompat.OnRequestPermissionsResultCallback#onRequestPermissionsResult](https://developer.android.com/reference/android/support/v4/app/ActivityCompat.OnRequestPermissionsResultCallback.html#onRequestPermissionsResult(int, java.lang.String[], int[]))は、ユーザ選択の結果を受けるコールバックメソッドだ

なお、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](https://qiita-image-store.s3.amazonaws.com/0/100612/df885360-5ca8-fa0d-fbcd-25029b306907.png)

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

![Screenshot_1518139112.png](https://qiita-image-store.s3.amazonaws.com/0/100612/401c4126-0f36-84ed-37da-66a8f4476e7b.png)

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

ボタンを押します。

![Screenshot_1518139161.png](https://qiita-image-store.s3.amazonaws.com/0/100612/ca3d8101-2dca-bd6a-4c48-53838d120d1f.png)

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

![Screenshot_1518139169.png](https://qiita-image-store.s3.amazonaws.com/0/100612/c6924100-aa93-5c57-4db1-41ee6c2b4826.png)

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

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

![Screenshot_1518139178.png](https://qiita-image-store.s3.amazonaws.com/0/100612/997c39dd-95dd-1468-765e-c6d24af4636c.png)

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

![Screenshot_1518139185.png](https://qiita-image-store.s3.amazonaws.com/0/100612/eb8ab054-7361-9db7-bba1-c97efafdaf88.png)

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

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

![Screenshot_1518139193.png](https://qiita-image-store.s3.amazonaws.com/0/100612/f73a8fbb-2623-bfa1-04ad-f184cca806e9.png)

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

# プログラム

## 初期画面

```java: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画面（一覧表示画面）

遷移先の画面です。

```java: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](https://developer.android.com/reference/android/content/ContentResolver.html#applyBatch(java.lang.String, java.util.ArrayList<android.content.ContentProviderOperation>))メソッドを使ったのは大袈裟でした（おかげで行数が嵩みました）が、まあそこはprivateなメソッドで纏めたのでご勘弁ください。

## マニフェスト

```xml: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にインストールして動作確認してみます。

```groovy: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](https://qiita-image-store.s3.amazonaws.com/0/100612/1677f2ef-75b1-9ef5-c238-18291d2ca0bd.png)

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

![Screenshot_1518139270.png](https://qiita-image-store.s3.amazonaws.com/0/100612/b9ff5760-c05f-a913-bbf6-c1bc2a1679b0.png)

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

![Screenshot_1518139288.png](https://qiita-image-store.s3.amazonaws.com/0/100612/6b04a3f6-a1e8-2773-658c-2874c5b8d3e8.png)

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

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

![Screenshot_1518139320.png](https://qiita-image-store.s3.amazonaws.com/0/100612/2c73287c-f565-4778-0f60-1369ed506d99.png)

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

# 謝辞

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

以上です。