ExpoのPermissions周りを実機検証してみる

## はじめに
Expoでの開発に限らず、iOSアプリでは一部のプライバシーに関わる機能を利用する際、その目的を説明する文言を設定する必要があります。設定した説明文は、ユーザーに権限を確認するためのダイアログに表示されます。

設定が必要な機能については下記のような記事が参考になりました。

**iOS のユーザデータにアクセスするための Info.plist への許可設定まとめ**
http://neos21.hatenablog.com/entry/2018/06/18/080000

**[iOS 10] 各種ユーザーデータへアクセスする目的を記述することが必須になるようです**
https://dev.classmethod.jp/smartphone/iphone/ios10-privacy-data-purpose-description/

Expoにおいてこの説明文（UsageDescription）は`app.json`の`expo.ios.infoPlist`オブジェクトに追加することでカスタマイズできます。
デフォルトだと説明文が存在しない訳ではなく、英語、かつ目的を説明していない(Give ~ permissionという感じの)ため、App Storeに申請する際、下記の記事に書かれているようにリジェクトされてしまいます。

**【React Native】【Expo】iOSのパーミッション要求ダイアログで審査リジェクトされた話とその対応**
https://tech.maricuru.com/entry/2018/07/27/195921

私が過去リジェクトされた際には目的がどうのというより、言語を統一しなさいという感じだったと思うので、目的をちゃんと説明しつつアプリが対象としている言語で設定する必要があるようです。

フォトライブラリ（カメラロール）、カメラ、通知、位置情報のパーミッション設定をiOS/Androidの実機で確認してみたので、その流れを説明します。

## 画面を作成

このようにPermissionを確認するボタンだけを表示するような画面を作ります。
<img width="375" alt="PNGイメージ 15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/4e2f9c8a-11f0-db3c-c0d5-ea1acecfbef0.png">

追加するモジュールはこのあたりです

* expo-image-picker
* expo-permissions
* expo-location
* expo-haptics これは全然関係ないけど使ってみたかったので。許可が得られた時に振動をつけてみます。

### Permission取得の処理

```jsx:PermissionButton.js
import React, { useState } from "react";
import {
  TouchableOpacity,
  Text,
  View,
  ActivityIndicator,
  Alert
} from "react-native";
import * as Haptics from "expo-haptics";
import { Linking } from "expo";
import * as Permissions from "expo-permissions";

function PermissionButton(props) {
  const { type, title } = props;
  const [isLoading, setLoading] = useState(false);
  const [isGranted, setGranted] = useState(false);

  return (
    <TouchableOpacity
      style={{
        width: "100%",
        height: 50,
        marginVertical: 12
      }}
      disabled={isLoading}
      onPress={async () => {
        if (isGranted) {
          props.onPress && props.onPress();
          return false;
        }
        setLoading(true);
        const { status } = await Permissions.askAsync(Permissions[type]);
        setLoading(false);
        if (status === "granted") {
          setGranted(true);
          await Haptics.notificationAsync(
            Haptics.NotificationFeedbackType.Success
          );
          setTimeout(props.onPress, 500);
        } else {
          // ユーザーが意図的にPermissionを不許可にしている場合
          // アラートを表示して設定画面に移動する
          Alert.alert(
            `${title}が無効になっています`,
            "設定画面へ移動しますか？",
            [
              {
                text: "キャンセル",
                style: "cancel"
              },
              {
                text: "設定する",
                onPress: () => {
                  Linking.openURL("app-settings:");
                }
              }
            ]
          );
        }
        console.log(type, status);
      }}
    >
      <View
        style={[
          props.style,
          {
            position: "relative",
            width: "100%",
            height: "100%",
            justifyContent: "center",
            alignItems: "center",
            backgroundColor: isGranted ? "#3cbe8d" : "#3cb3ff",
            borderRadius: 25
          }
        ]}
      >
        {isLoading ? (
          <ActivityIndicator
            color="#ffffff"
            style={{
              position: "absolute",
              width: "100%",
              height: "100%",
              top: 0,
              left: 15,
              justifyContent: "center",
              alignItems: "flex-start"
            }}
          />
        ) : null}
        {isGranted ? (
          <Text
            style={{
              position: "absolute",
              width: "100%",
              height: "100%",
              lineHeight: 50,
              top: -2,
              left: 15,
              justifyContent: "center",
              alignItems: "flex-start"
            }}
          >
            &#x1f44d;
          </Text>
        ) : null}
        <Text
          style={{
            color: "#ffffff",
            fontWeight: "bold"
          }}
        >
          {title}
        </Text>
      </View>
    </TouchableOpacity>
  );
}

export default PermissionButton;
```

このように、指定したPermissionをaskするボタンをコンポーネント化しておきました。

### 画面を作成し、各機能を確認する処理を追加

```jsx:App.js
import React from "react";
import { Text, View, Alert } from "react-native";
import PermissionButton from "./PermissionButton";
import * as ImagePicker from "expo-image-picker";
import * as Location from "expo-location";
import { Notifications } from "expo";

export default function App() {
  return (
    <View
      style={{
        flex: 1,
        padding: 32,
        backgroundColor: "#fff",
        alignItems: "center",
        justifyContent: "center"
      }}
    >
      <Text
        style={{
          fontSize: 24,
          marginBottom: 16,
          fontWeight: "bold"
        }}
      >
        パーミッション確認
      </Text>
      <PermissionButton
        type="CAMERA_ROLL"
        title="フォトライブラリ（カメラロール）"
        onPress={async () => {
          try {
            const result = await ImagePicker.launchImageLibraryAsync();
            console.log(result);
          } catch (error) {
            Alert.alert(error.message);
          }
        }}
      />
      <PermissionButton
        type="CAMERA"
        title="カメラ"
        onPress={async () => {
          try {
            const result = await ImagePicker.launchCameraAsync();
            console.log(result);
          } catch (error) {
            Alert.alert(error.message);
          }
        }}
      />
      <PermissionButton
        type="NOTIFICATIONS"
        title="PUSH通知"
        onPress={async () => {
          try {
            const token = await Notifications.getExpoPushTokenAsync();
            console.log("token", token);
            const res = await fetch("https://expo.io/--/api/v2/push/send", {
              method: "POST",
              headers: {
                Accept: "application/json",
                "Content-type": "application/json"
              },
              body: JSON.stringify({
                to: token,
                title: "通知テスト",
                body: "ここにbodyが入ります",
                _displayInForeground: true
              })
            });
            console.log(res);
          } catch (error) {
            Alert.alert(error.message);
          }
        }}
      />
      <PermissionButton
        type="LOCATION"
        title="位置情報"
        onPress={async () => {
          try {
            const { coords } = await Location.getCurrentPositionAsync();
            Alert.alert(
              "位置情報の取得に成功しました",
              `緯度：${coords.latitude}\n経度：${coords.longitude}`
            );
          } catch (error) {
            Alert.alert(error.message);
          }
        }}
      />
    </View>
  );
}
```

このように画面にボタンを配置して、各機能を確認するだけの処理を実装します。
通知に関しては送信が確認できればいいので、ExpoのAPIを直接叩いています。その際、iOSでもアプリ起動時に通知が表示されるように`_displayInForeground: true`をオプションに指定しておきます。

### `app.json`の編集

```json:app.json
{
  "expo": {
    "name": "許可テスト",
    "slug": "permission-test",
    "privacy": "public",
    "sdkVersion": "36.0.0",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#3cb3ff"
    },
    "updates": {
      "fallbackToCacheTimeout": 0
    },
    "assetBundlePatterns": [
      "**/*"
    ],
    "ios": {
      "supportsTablet": true,
      "bundleIdentifier": "バンドル名",
      "infoPlist": {
        "NSCameraUsageDescription": "[テスト文言]写真をアップロードするためにカメラを使用します",
        "NSPhotoLibraryUsageDescription": "[テスト文言]アカウント画像をアップロードするためにフォトライブラリを使用します",
        "NSLocationAlwaysUsageDescription": "[テスト文言]位置情報をアップロードします",
        "NSLocationUsageDescription": "[テスト文言]位置情報をアップロードします",
        "NSLocationWhenInUseUsageDescription": "[テスト文言]位置情報をアップロードします"
      }
    },
    "android": {
      "package": "パッケージ名",
      "permissions": [
        "ACCESS_COARSE_LOCATION",
        "ACCESS_FINE_LOCATION",
        "CAMERA",
        "READ_EXTERNAL_STORAGE",
        "WRITE_EXTERNAL_STORAGE",
        "VIBRATE",
      ]
    },
    "notification": {
      "icon": "./assets/notificationicon.png",
      "color": "#3cb3ff"
    }
  }
}
```

`app.json`はこんな感じで設定しました。
`expo.ios.infoPlist`に各UsageDescriptionを、`expo.android.permissions`には必要なだけの機能を記載します。
一応`expo.notification`でAndroid用の通知アイコンも設定しておきました。（詳しくは[こちらの記事](https://qiita.com/mildsummer/items/9dbfac3bf2ef857ca74f#android%E3%81%AEnotification%E3%82%A2%E3%82%A4%E3%82%B3%E3%83%B3%E3%82%92%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%9E%E3%82%A4%E3%82%BA%E3%81%99%E3%82%8B)）
`expo.android.permissions`の`VIBRATE`は、`expo-haptics`を使うので入れています。

## 検証

#### Android

実機での検証はAndroidの方が圧倒的に楽なので先にやってみます。
Androidの場合も6.0から一部機能でダイアログが表示されますが、この文言は設定できないので、
適切な`expo.android.permissions`があれば申請の際に気にする必要はありません。
Expo Clientでの確認で十分な気もしつつ、念の為スタンドアロンアプリとしてビルドし実機で確認します。

```console:ビルド
$ expo build:android
```

ビルド後に表示されたURLからダウンロードし、
端末を繋げてからAndroid SDKの`adb install`コマンドでAPKファイルのパスを指定してインストールします。

```console:インストール
$ adb install permission-test-xxxxxx.apk
```

各Permissionを確認してみます。

**カメラ**
<img width="375" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/5455274c-cb59-f103-ac2f-79025e655275.jpeg" alt="android">

**フォトライブラリ**
<img width="375" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/8bb19bbc-47cc-0341-fc53-e8b918488b61.jpeg" alt="android">

**通知**
通知の許可に関してはダイアログが出ず通りました。
問題なく送信され、通知アイコンの設定も上手くいきました。色とアイコン（鍵）の部分が独自デザインです。
<img width="375" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/ffb0ed0c-8835-788e-0913-b44c9b310d15.jpeg" alt="android">

**位置情報**
<img width="375" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/b9f2048e-8738-af9a-e7b2-df89de7c7103.jpeg" alt="android">

以上、Androidはこんな感じです。

#### iOS

iOSの場合、`ios.infoPlist`の内容はExpo Client上では反映されないため、スタンドアロンアプリとしてビルドしてみないと検証できません。
しかし、スタンドアロンアプリを実機で確認する場合、リリースビルドをApp StoreやTestFlight経由でインストールするしか方法がありません（少なくとも公式には）。

今回はTestFlightの内部テストで確認してみたいと思います。

途中までは通常のアプリ申請のフローと同じなので、
[App Storeへの申請についての記事](https://qiita.com/mildsummer/items/e98b1b8e4ea7f72b9899)の
[**iOS用にビルド（Apple Developer Programにメンバーシップ登録後）**](https://qiita.com/mildsummer/items/e98b1b8e4ea7f72b9899#ios%E7%94%A8%E3%81%AB%E3%83%93%E3%83%AB%E3%83%89apple-developer-program%E3%81%AB%E3%83%A1%E3%83%B3%E3%83%90%E3%83%BC%E3%82%B7%E3%83%83%E3%83%97%E7%99%BB%E9%8C%B2%E5%BE%8C)
セクションから

[**アプリ（ipaファイル）をApp Store Connectにアップロード**](https://qiita.com/mildsummer/items/e98b1b8e4ea7f72b9899#%E3%82%A2%E3%83%97%E3%83%AAipa%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92app-store-connect%E3%81%AB%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89)
セクションまでと同じ手順でビルドからアップロードまでを行います。

Transporterでアップロードする場合はこんな感じに。
<img width="840" alt="空白_Skitch_キャンバス.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/130df6ac-f872-65ce-55ea-5aa4b4f3d531.jpeg">

そしてアップロードが完了した後、App Store ConnectのTestFlightタブを見るとこんな感じです。
<img width="1440" alt="App_Store_Connect_2.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/ca6903dd-dae7-a344-9011-deeb6c742c73.jpeg">

何らかの処理をしているようなので、しばらく待ちます。数十分から一時間ほどかかります。。。
しばらくしてAppleからメールが届くかと思います。もう一度App Store Connectを見ると、

<img width="1440" alt="App_Store_Connect3.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/7ae47893-2894-3892-b1e0-9b9cca49e3ac.jpeg">

米国の輸出コンプライアンスへの確認が求められます。「！」アイコンから進み質問に答えてから、

<img width="1440" alt="App_Store_Connect4.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/f6f3bbf1-c2ae-2806-075b-3f090da0f9f0.jpeg">
<img width="1440" alt="App_Store_Connect5.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/f27c1127-6bc3-e1cb-9fb5-454f37d2e4ae.jpeg">

テスターに自分を招待して、iOS端末にTestFlightをインストールして確認します。

<img width="375" alt="PNGイメージ 14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/ff71c84a-71c9-7990-46d8-f4c456a29be0.png">

各Permissionを確認してみます。

**フォトライブラリ**
<img width="375" alt="PNGイメージ 24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/d39f3c10-f181-285c-b49a-e50f927ce22e.png">

「`"アプリ名"が〜を求めています`」という見出しは端末の言語設定によってiOSが固定で入れている文言です。
その下の部分に、`app.json`で設定した説明文が表示されています。

**カメラ**
<img width="375" alt="PNGイメージ 19.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/f6d787d0-49c2-9c6d-2a59-9702daa2d006.png">
こちらもOK。

**通知**
<img width="375" alt="PNGイメージ 21.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/4d7936f5-f857-762e-259a-ae809cc5f78f.png">
![PNGイメージ_22.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/0e97acfc-fe69-61fc-54a6-eca76898cff6.jpeg)
通知の場合は、もともとOSで決められたものが表示されます。
PUSH通知も問題なく届きました。

**位置情報**
<img width="375" alt="PNGイメージ 23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/7df74d03-559b-206d-d653-0e92e86da027.png">
設定した説明文が表示されました。

## 再確認の方法

iOSの許可ダイアログは一回しか表示されず、「設定」から各アプリ・各機能へのアクセスを不許可にしても再度表示することはできません。
再度ダイアログを表示させるには、「設定」→「一般」→「リセット」→「位置情報とプライバシーをリセット」で全てのアプリのプライバシー設定ごとリセットするか、アプリを再インストールするしか方法は無いようです。

TestFlightではアプリをアンインストールしてからこのようにすぐ再インストールすることが可能ですので、App Store経由の場合よりは比較的簡単にリセットできるかと思います。
<img width="375" alt="PNGイメージ 25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/63fc1283-b37b-3d36-5d98-adc293acc6fb.png">

Androidの場合は「設定」→「アプリと通知」→アプリを選択し、「権限」から各機能の許可を外すことができます。
その場合はiOSと違って、リクエストした際には再び許可ダイアログが表示されます。
<img width="375" alt="android" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/a082efeb-f2ce-37f0-ccc1-2982e85316a5.jpeg">