Expo SDK37のBare WorkflowでNotificationsを使ってみる

## はじめに

4月1日にExpoのSDK37がリリースされました。詳しい変更内容は下記のURLから見ることができます。

https://dev.to/expo/expo-sdk-37-is-now-available-69g
https://github.com/expo/expo/blob/master/CHANGELOG.md#3700

大きな変化として、まずはEject後の選択肢がExpoKitからBare Workflowへ完全に移行した点が挙げられます。
そして単にリプレイスされただけという訳ではなく、これまでManaged Workflowのみ対応していたNotifications APIがBare Workflowでも使用できるようになり、APIの内容も変化しています。

この記事ではSDK37のNotifications APIをBare Workflowで使用するための導入方法や検証結果をまとめてみます。
ちなみに途中でエラー解決のためにソースをいじっていますが、私自身がAPIへの理解を深めることを目的とした其の場凌ぎの感があるので、あくまでそれら全てをお勧めしているわけではありません。

## プロジェクトを作成

既存のプロジェクトをアップデートするのではなく、プロジェクトを作成するところからやってみます。
まずは（していなければ）`expo-cli`をアップデート。

```console
$ npm update -g expo-cli
```

`expo init`して`Bare workflow`のテンプレートからプロジェクトを作成します。

```console
$ expo init BareNotification
? Choose a template:
❯ minimal               bare and minimal, just the essentials to get you started
```

作成されたプロジェクトディレクトリを開き、`package.json`を見ると見慣れない`expo-updates`というパッケージがデフォルトでインストールされているのがわかります。後述しますがこのパッケージは今のところリリースビルドする時にエラーの原因になってしまいます。

### バンドル/パッケージ名に注意

ここで注意したいのが、Expoの以前のバージョンでもそうですが、最初からBare Workflowのテンプレートでプロジェクトを作成した場合はiOSのbundleIdentifierやAndroidのパッケージ名がプロジェクト名を元に勝手に命名されてしまうという点です。変更したい場合は一括置換、およびAndroidのjavaディレクトリの修正などを適切に行ってください。
（なので、実際の開発ではManaged Workflowでプロジェクトを作成し`app.json`のバンドル・パッケージ名、アイコン、スプラッシュ等を編集してからEjectというのがスマートです。）

## パッケージをインストール

画面の実装の方は後述するとして設定を先に行いますが、最初に使用するパッケージをインストールしておきます。

```console
$ npm install expo-notifications expo-web-browser \
@react-navigation/native @react-navigation/stack \
react-native-reanimated react-native-gesture-handler \
react-native-screens react-native-safe-area-context \
@react-native-community/masked-view
```

多く見えますが、`react-navigation`の依存パッケージが結構あります。

## Notificationsの設定

公式のREADMEに沿って設定を行います。所々リンクが通っていないので、察しながらやっていきます。
https://github.com/expo/expo/tree/master/packages/expo-notifications#installation-in-bare-react-native-projects

### iOSの設定

#### ライブラリをインストール

通常のプロセスですが、上記のnpmパッケージをインストールした上で、CocoaPodsでライブラリをインストールします。

```console:ライブラリをインストール
$ cd ios
$ pod install
$ cd ..
```

#### Capabilityを追加

Xcodeでプロジェクト(.xcworkspace)を開き、Signing & CapabilitiesタブでPush NotificationsのCapabilityを追加します。
<img width="1370" alt="BareNotifications_xcodeproj.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/290c2704-4149-f18f-b433-a4c51b4dd035.png">

Team、Bundle IdentifierなどのSigningの設定も適宜行ってください。

#### プロジェクトにAPNSキーを紐付ける

Managed WorkflowではExpoが勝手にやってくれていた部分を手動で行います。(といっても、これもコマンドを叩けばいいだけです。)

```console
$ expo credentials:manager
? What do you want to do?
  ---- Current project actions ----
❯ Use existing Push Notifications Key in current project
  Use existing Distribution Certificate in current project
  ---- Account level actions ----
  Remove Provisioning Profile
  Add new Push Notifications Key
  Remove Push Notification credentials
  Update Push Notifications Key
  Add new Distribution Certificate
  Remove Distribution Certificate
  Update Distribution Certificate
```

色々と情報が出たあとに何をしたいか聞かれるので、Expoを使ってすでにPush Notifications Keyが生成されている場合は`Use existing Push Notifications Key in current project`を選択し、プロジェクトにキーを紐付けます。
Push Notifications Keyはデベロッパーごとに２つまでと決まっているので基本的にはプロジェクトを跨って使い回すことになります。まだ作成していない場合は`Add new Push Notifications Key`を選択したうえで生成されたキーをプロジェクトに紐付けてください。

### Androidの設定

AndroidではFCM(Firebase Cloud Messaging)を使った実装になっているため、Firebaseプロジェクトが必要になります。ここでは、すでにFirebaseプロジェクトを作成しているという前提で進めます。

#### AndroidプロジェクトとFirebaseプロジェクトの紐付け

設定項目は具体的にはFirebase Android 構成ファイル（`google-services.json`）の設置とGradleファイルの編集などですが、アップデートが速いので、下記の公式のドキュメントに沿って行ってください。

**Android プロジェクトに Firebase を追加する**
https://firebase.google.com/docs/android/setup?hl=ja

#### FCMサーバーキーのアップロード

Expoから通知を送るためには、Expo側にFCMサーバーキーをアップロードする必要があります。
Firebase Consoleの設定 > 「クラウド メッセージング」タブ内のサーバーキー（下画像）をコピーし、
<img width="1547" alt="Test_Project_–_Firebase_console.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/43abebe1-89b1-b501-7fbf-f5c4649b05c4.png">
下記のコマンドでこれをアップロードします。

```console
$ expo push:android:upload --api-key <サーバーキー>
```

[参考]**Uploading Server Credentials**
https://docs.expo.io/versions/latest/guides/using-fcm/#uploading-server-credentials

## 画面の作成

さて、設定が完了したので確認のための画面を作りますが、ここでは以前下記の記事で作成した画面遷移のパターンを試してみることにします。
https://qiita.com/mildsummer/items/6e13800c04a91dd3c2d6
`react-navigation`がv4系からv5系で割と大きく変化しているようなので、それも修正します。ついでにHooksに書き直した結果、最終的なソースがこちらです。

```jsx:App.js
import "react-native-gesture-handler";
import React, { useEffect, useState, createRef } from "react";
import { Text, View, Alert, Platform } from "react-native";
import { NavigationContainer, StackActions } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import * as Notifications from "expo-notifications";
import * as WebBrowser from "expo-web-browser";

function Top() {
  const [token, setToken] = useState(null);

  useEffect(() => {
    init().catch(console.error);
  }, []);

  async function init() {
    const settings = await Notifications.requestPermissionsAsync();
    if (settings.granted || settings.status === 1) {
      const { data: token } = await Notifications.getExpoPushTokenAsync({
        experienceId: "@<ユーザー名>/<プロジェクト名>"
      });
      console.log("[EXPO PUSH TOKEN]", token);
      console.log(
        `Try running the command "node pushNotification.js ${
          token.match(/\[(.+)]/)[1]
          } [SCREEN NAME(A|B|C) or URL] [ID]"`
      );
      console.log(
        `ex) $ node pushNotification.js ${token.match(/\[(.+)]/)[1]} A 123`
      );
      setToken(token);
    } else {
      console.log("permission denied");
    }
  }

  return (
    <View
      style={{
        flex: 1,
        width: "100%",
        justifyContent: "center",
        alignItems: "center"
      }}
    >
      <Text>{token || "getting token..."}</Text>
      <Text>Please look your console</Text>
    </View>
  );
}

const Stack = createStackNavigator();
const navigationRef = createRef();
const stackScreens = [
  { name: "A", color: "#6200EE" },
  { name: "B", color: "#03DAC6" },
  { name: "C", color: "#B00020" }
];
let currentNavigationState = null;

export default function App() {
  let subscription = null;

  useEffect(() => {
    Notifications.setNotificationHandler({
      handleNotification: async (notification) => {
        // console.log(notification);
        return {
          shouldShowAlert: true,
          shouldPlaySound: true,
          shouldSetBadge: true,
        };
      }
    });
    Notifications.addNotificationResponseReceivedListener(response => {
      console.log("response", response);
      respond(response.notification);
      Alert.alert("通知が届きました", JSON.stringify(response.notification.request.content.data));
    });
    subscription = Notifications.addNotificationReceivedListener((notification) => {
      console.log("received notification");
      console.log(notification);
      Alert.alert("通知が届きました", JSON.stringify(notification.request.content.data), [
        {
          text: "キャンセル",
          style: "cancel"
        },
        {
          text: "移動する",
          onPress: () => {
            respond(notification);
          }
        }
      ]);
    });
  }, []);

  function respond(notification) {
    const data = Platform.OS === 'ios' ? notification.request.content.data?.body : notification.request.content.data;
    if (data && data.url) {
      WebBrowser.openBrowserAsync(data.url).catch(console.error);
    } else if (data && navigationRef.current) {
      const routeName = data.routeName || "top";
      const params = data.params;
      if (currentNavigationState && currentNavigationState.routes.length > 1) {
        // 同階層の場合はスタック追加せず差し替える
        navigationRef.current.dispatch(StackActions.replace(routeName, params));
      } else {
        navigationRef.current.navigate(routeName, params);
      }
    }
  }

  return (
    <NavigationContainer
      ref={navigationRef}
      onStateChange={(currentState) => {
        currentNavigationState = currentState;
      }}
    >
      <Stack.Navigator initialRouteName="top">
        <Stack.Screen name="top" options={{ headerShown: false }} component={Top} />
        {stackScreens.map((screen) => (
          <Stack.Screen
            key={screen.name}
            name={screen.name}
            options={({ route }) => {
              headerTitle: `${screen.name} ID:${route.params.id}`
            }}
          >
            {({ route }) => (
              <View
                style={{
                  flex: 1,
                  width: "100%",
                  justifyContent: "center",
                  alignItems: "center",
                  backgroundColor: screen.color
                }}
              >
                <Text style={{ color: "#FFF", fontSize: 16 }}>screen name</Text>
                <Text style={{ color: "#FFF", fontSize: 24 }}>{screen.name}</Text>
                <Text style={{ color: "#FFF", fontSize: 16, marginTop: 24 }}>ID</Text>
                <Text style={{ color: "#FFF", fontSize: 24 }}>
                  {route.params.id || "-"}
                </Text>
              </View>
            )}
          </Stack.Screen>
        ))}
      </Stack.Navigator>
    </NavigationContainer>
  );
}
```

挙動は変わりませんが、v5ではReactの要素として（JSXの中に）スクリーンを定義するようになっています。
`expo`パッケージ内のNotificationsと`expo-notifications`でAPIが結構変わっているのがわかるでしょうか。詳しい内容は[README](https://github.com/expo/expo/tree/master/packages/expo-notifications#api)を参照してください。
また、後述する検証の結果、iOSとAndroidでnotificationオブジェクト内のdataの入り方が違っていたので、`Platform.OS`を見て処理を分けています。
**`getExpoPushTokenAsync`している箇所では、オプションの`experienceId`を`@<ユーザー名>/<プロジェクト名>`の形式で入れてください。**プロジェクト名は正確には`app.json`内の`expo.slug`の値（のはず）です。

### ExpoのpushAPIを叩く処理

上述した以前の記事と同じように、便宜的にAPIを叩く処理を用意しておきます。

```js:pushNotification.js
const request = require("request");

const [, , token, routeName, id] = process.argv;

const isWebView = /http/.test(routeName);

request(
  {
    url: "https://expo.io/--/api/v2/push/send",
    method: "POST",
    json: {
      to: `ExponentPushToken[${token}]`,
      title: "通知サンプル",
      body: "ここに説明文が入ります",
      data: isWebView
        ? {
            url: routeName,
            params: { id }
          }
        : {
            routeName,
            params: { id }
          },
      _displayInForeground: true
    }
  },
  function(error, response, body) {
    if (error) {
      console.log(error);
    } else {
      console.log(body);
    }
  }
);
```

## 実機で検証してみる

### Expo Client

READMEには、Expo Clientではまだ使用できないと書いてあります。
https://github.com/expo/expo/tree/master/packages/expo-notifications#installation-in-managed-expo-projects

実際に確認すると、`expo-notifications`をインポートしている時点で`Native module cannot be null.`となり、使用できません。
<img width="375" alt="PNGイメージ 48.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/4b6a035f-fb7e-161e-6d67-98e4529cbc9e.png">

### Xcode + iPhoneでデバッグビルドを確認

iOSアプリをXcodeで動かしてみます。端末を繋ぎ、Product→Runします。
トークンが表示され、
<img width="375" alt="PNGイメージ_48.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/af038e29-d728-04e6-7511-e27de3963bc2.png">

先ほどのスクリプトで通知を送ってみます。

```console
$ node pushNotification.js <トークン文字列部分> A 123
```

<img width="375" alt="PNGイメージ_50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/9b4af5ff-0f3f-5943-d6ab-a1c2b61b48e2.png">
<img width="375" alt="PNGイメージ 51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/f6b4f0dc-3685-94b7-396c-d4f866d638a8.png">

アプリのforeground/bacground状態共に問題なく通知が届き、通知に含まれるデータを取得することでページ遷移の実装も正常に動いています。

私の場合は最初は上手くいかなかったのですが、上述した`expo credentials:manager`コマンドによって既存のPush Notifications Keyを削除し、新しく生成しなおすと成功しました。

### Androidでデバッグビルドを確認

USBデバッグが有効になっている端末をつなぎ`adb devices`でデバイスが検知できているのを確認したうえで、`react-native`コマンドを叩いて起動します。

```console
$ npx react-native run-android
```

<img width="375" alt="PNGイメージ_50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/79e02e3a-0d56-8fe4-7425-6105dd66ed93.png">
FirebaseとFCMとの連携が上手くいっていれば、Androidでも問題なくトークンが表示されます。

しかし実際に通知を送ってみると、アプリがクラッシュしてしまいました。
`adb logcat`で探ってみると、このようなエラーが発生しています。

```
java.lang.UnsupportedOperationException: Could not put a value of class android.os.Bundle to bundle.
```

どうやらこのエラーは、通知のパラメータに`title`や`body`だけでなく`data`を入れると発生することがわかりました。
環境依存などではなく純粋にネイティブモジュールのバグのようなので、モジュール自体に手を入れる以外解決方法はありません。
余裕があればPRを送るとして、一時的な解決方法として`node_modules/expo-notifications`の中を編集してみます。

#### `data`エラー（Could not put a value of class android.os.Bundle to bundle.)回避

`data`パラメータを指定したい場合、まずはunimodules内の[MapArguments](https://github.com/expo/expo/blob/master/packages/%40unimodules/core/android/src/main/java/org/unimodules/core/arguments/MapArguments.java)を新しいファイルで置き換えます。

```java:node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/MapArguments.java
package expo.modules.notifications.notifications;

import android.os.Bundle;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.unimodules.core.arguments.ReadableArguments;

public class MapArguments implements ReadableArguments {
  private Map<String, Object> mMap;

  public MapArguments() {
    mMap = new HashMap<>();
  }

  public MapArguments(Map<String, Object> map) {
    mMap = map;
  }

  @Override
  public Collection<String> keys() {
    return mMap.keySet();
  }

  @Override
  public boolean containsKey(String key) {
    return mMap.containsKey(key);
  }

  @Override
  public Object get(String key) {
    return mMap.get(key);
  }

  @Override
  public boolean getBoolean(String key) {
    return getBoolean(key, false);
  }

  @Override
  public boolean getBoolean(String key, boolean defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof Boolean) {
      return (Boolean) value;
    }
    return defaultValue;
  }

  @Override
  public double getDouble(String key) {
    return getDouble(key, 0);
  }

  @Override
  public double getDouble(String key, double defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof Number) {
      return ((Number) value).doubleValue();
    }
    return defaultValue;
  }

  @Override
  public int getInt(String key) {
    return getInt(key, 0);
  }

  @Override
  public int getInt(String key, int defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof Number) {
      return ((Number) value).intValue();
    }
    return defaultValue;
  }

  @Override
  public String getString(String key) {
    return getString(key, null);
  }

  @Override
  public String getString(String key, String defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof String) {
      return (String) value;
    }
    return defaultValue;
  }

  @Override
  public List getList(String key) {
    return getList(key, null);
  }

  @Override
  public List getList(String key, List defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof List) {
      return (List) value;
    }
    return defaultValue;
  }

  @Override
  public Map getMap(String key) {
    return getMap(key, null);
  }

  @Override
  public Map getMap(String key, Map defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof Map) {
      return (Map) value;
    }
    return defaultValue;
  }

  @Override
  public boolean isEmpty() {
    return mMap.isEmpty();
  }

  @Override
  public int size() {
    return mMap.size();
  }

  @Override
  public ReadableArguments getArguments(String key) {
    Map value = getMap(key);
    if (value != null) {
      return new MapArguments(value);
    }
    return null;
  }

  @Override
  public Bundle toBundle() {
    Bundle bundle = new Bundle();
    for (String key : mMap.keySet()) {
      Object value = mMap.get(key);
      if (value instanceof String) {
        bundle.putString(key, (String) value);
      } else if (value instanceof Integer) {
        bundle.putInt(key, (Integer) value);
      } else if (value instanceof Double) {
        bundle.putDouble(key, (Double) value);
      } else if (value instanceof Long) {
        bundle.putLong(key, (Long) value);
      } else if (value instanceof Boolean) {
        bundle.putBoolean(key, (Boolean) value);
      } else if (value instanceof ArrayList) {
        bundle.putParcelableArrayList(key, (ArrayList) value);
      } else if (value instanceof Map) {
        bundle.putBundle(key, new MapArguments((Map) value).toBundle());
      } else if (value instanceof Bundle) { // Bundleクラスインスタンスをセットする場合を追加
        bundle.putBundle(key, (Bundle) value);
      } else {
        throw new UnsupportedOperationException("Could not put a value of " + value.getClass() + " to bundle.");
      }
    }
    return bundle;
  }
}
```

続いて`node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/NotificationSerializer.java`を編集します。

```java:node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/NotificationSerializer.java
package expo.modules.notifications.notifications;

import android.os.Bundle;
import android.util.Log;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
- import org.unimodules.core.arguments.MapArguments; // この行を削除

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import androidx.annotation.Nullable;
+ import expo.modules.notifications.notifications.MapArguments; // この行を追加
import expo.modules.notifications.notifications.interfaces.NotificationTrigger;
import expo.modules.notifications.notifications.model.Notification;
import expo.modules.notifications.notifications.model.NotificationContent;
import expo.modules.notifications.notifications.model.NotificationRequest;
import expo.modules.notifications.notifications.model.NotificationResponse;
import expo.modules.notifications.notifications.model.triggers.FirebaseNotificationTrigger;
import expo.modules.notifications.notifications.triggers.DateTrigger;
import expo.modules.notifications.notifications.triggers.TimeIntervalTrigger;

public class NotificationSerializer {
  // 省略
}
```

こちらはimportしているモジュールを置き換えているだけです。

#### 再度確認してみる

ネイティブモジュールを修正した後、再度確認してみると通知を処理できるようになりました。
この際、リスナー関数の引数になるNotificationオブジェクトの構造がiOSと違ったので、上述したように処理を分けています。
<img width="375" alt="Screenshot_20200403-165400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/f06ef991-4a37-9e54-aa15-2b9bb47b963b.png">
<img width="375" alt="Screenshot_20200403-165400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/94f23e7e-b26f-48c2-8cf0-736903682c32.png">

Androidにおいてはアプリが終了している状態で届いた通知をタップしたときに、リスナー関数が呼ばれないなどiOSと少し挙動が異なります。これについては別記事で改めて詳しく調べようと思います。

### Xcode + TestFlight + iPhoneでリリースビルドを確認

プロジェクトをArchiveしApp Storeにアップロード、TestFlightで確認というプロセスでiOSのリリースビルドを確認します。
この時点でまずはApp Store Connectで同じバンドル名の新規Appを追加しておく必要があります。

さて、このまま普通にProduct→Archiveしてすんなりビルドできればいいのですが、
もしnvmでNode.jsのバージョン管理をしている場合は最後の方でこのようなエラーが発生してしまいます。

```
No specify using Node.js version.
Command PhaseScriptExecution failed with a nonzero exit code
```

これ自体はTARGET設定のBuild Phasesの中にある`Bundle Expo Assets`というフェーズの行頭に、nvmへのPATHを通している`.bashrc`なり`.bash_profile`なりへの参照を追加すれば解決します。
![BareNotifications_xcodeproj.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/731125ad-1f53-3203-3b40-7a919abdeb43.png)

しかしこれで一旦はビルドが通りTestFlightで確認しようと思っても、アプリを起動した瞬間にクラッシュしてしまうと思います。
Expo SDK37以降、Bare Workflowではデフォルトで`expo-updates`パッケージがインストールされており(これはインストールの時にも書きましたが)、この設定を正しく行わないと起動時にエラーが発生してしまうからです。

`expo-updates`自体はBare WorkflowでOTAアップデートができるようになるありがたいパッケージです。ただ、今現在`expo-updates`の[README](https://github.com/expo/expo/tree/master/packages/expo-updates)を見ても設定方法がいまいちよくわからない（特に、`app.manifest`の書き方など）のと、今回はNotificationsの検証がしたいだけなので、`expo-updates`を使用している部分を削除することで検証を進めたいと思います。

#### `expo-updates`使用部分を削除

npmパッケージやPodsをアンインストールするかどうかはお任せするとして、コード内の`expo-updates`使用部分を削除します。基本的にはREADMEに書いてあるInstallationの逆のことをすればよいはずです。

##### AppDelegate.mを修正

`expo-updates`が導入される前の状態に戻します。
Githubでみると２０１９年4月のこの辺りです（わりと最近まで変化無いようです）。コピペして、モジュール名が`HelloWorld`になっている部分を`main`に直します。
https://github.com/expo/expo/blob/c9802eba34e4b88335aac24e76f8e24d990d2bd1/templates/expo-template-bare-minimum/ios/HelloWorld/AppDelegate.m

##### AppDelegate.hを修正

こちらも同様。
https://github.com/expo/expo/blob/c9802eba34e4b88335aac24e76f8e24d990d2bd1/templates/expo-template-bare-minimum/ios/HelloWorld/AppDelegate.h

##### Build Phasesを修正

`Bundle Expo Assets`のコードを置き換えます。

```shell
export NODE_BINARY=node
../node_modules/react-native/scripts/react-native-xcode.sh
```

![BareNotifications_xcodeproj.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/217df9db-4af4-d885-12f7-25fad45dd026.png)

これでコード内にある`expo-updates`の使用箇所がなくなりました。
その他、`Expo.plist`や`app.manifest`などは不要になるので削除して大丈夫です。

#### 再度確認

Archive、アップロードが完了し、TestFlightで確認してみると、無事通知を送ることができました。挙動はデバッグビルドと変わりありません。
<img width="375" alt="PNGイメージ_52.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/0aaa0c3a-5343-2c53-25d2-7601bcf8f31a.png">

### Androidでリリースビルドを確認

Androidの場合も同じく、そのままリリースビルドするとクラッシュしてしまうので、`expo-updates`部分を削除します。
こちらも[Configure for Android](https://github.com/expo/expo/tree/master/packages/expo-updates#configure-for-android)の逆をすればいいんですが、iOSと違って行の削除・フラグの変更だけなので簡単だと思います。

編集するファイルは`app/build.gradle`、`MainApplication.java`、`AndroidManifest.xml`です。(`MainApplication.java`の一部をコメントアウトするだけでも大丈夫です。)

Androidのリリースビルドの方法は人によって色々ありそうなので割愛します。
私の場合はAndroid Studioではnvmの関係でビルドできなかったりしたので、署名を含めてコマンドラインでやっています。

Androidでもリリースビルドの挙動が確認できました。
<img width="375" alt="PNGイメージ_52.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/46812a9e-8bf7-1394-bbe7-1e0d070e163e.png">

## まとめ

* `expo`パッケージ内の`Notifications`APIと今回新たに追加された`expo-notifications`では実装方法が異なる
* AndroidではFCMを使用するのでFirebaseプロジェクトが必須
* Expo Clientでは使用できない
* iOSのデバッグビルドは正常に動作
* Androidではネイティブモジュールの不具合で`data`パラメータを使用できない
    * モジュールを上書きすることで無理やり対処することは可能
* iOS、Android共にデフォルトでインストールされている`expo-updates`の影響でそのままではリリースビルドがクラッシュする。
    * `expo-updates`の設定が必要だが、よくわからない場合はこれを使用しないという選択肢もある
* Androidではそのほか、background/foregroundの挙動やNotification Iconの設定方法などよくわからない部分が残っている