みかづきブログ・カスタム

基本的にはちょちょいのほいです。

Expoでつくったアプリにローカルサーバからプッシュ通知を送ってバッジをつける(Expoの導入・プッシュ通知を受信するアプリの作成・プッシュ通知を送信するローカルサーバの作成) 📛

結論

こちらのドキュメントを見ながら実装したところ、iOSでは驚くほど簡単に実装できました。

docs.expo.dev

ただし、


https://docs.expo.dev/push-notifications/sending-notifications/ より引用

の通り、アプリケーションバッジに関しては、Android非対応の模様。
プッシュ通知の受信、通知をタップした際のイベントの登録事態はAndroidでも動作しました。


ことの発端

僕はかつて、アプリを開くことなく、千葉ロッテマリーンズの試合結果がわかるアプリ、chibadge(チバッジ)を開発運用していました。

chibadge.kimizuka.fm

しかし、アップデート申請がリジェクトされ続けるため、現在は泣く泣くストアから取り下げていたのです。

が。ここ2年、千葉ロッテマリーンズの調子が非常に良い。(2年連続2位)

ということで、chibadgeを復活させるべく、アプリの改修を始めようと思ったのですが、当時開発に使っていたツールが終了していました。。

www.axway.com

通知を受け取るだけのアプリなので、Swiftで書き直してもよかったのですが、これを機に最近気になっていたExpoを調べてみることにしてみた次第です。


Expoの調査

ExpoはReact Nativeをつかったネイティブアプリ開発をサポートするフレームワークです。

導入の決め手は、

・普段からReactのフレームワークであるNext.jsを使っているので、Reactの記法には慣れている
・プッシュ通知の証明書が不要(Expoが用意してくれている)
・Androidアプリも同時につくれる ※ のちにアプリケーションバッジに対応していないことを知る
・開発時はホットリロードが効く

の4点(のちに3点)です。
特に、プッシュ通知は証明書周りが面倒という記憶があり、それが不要というのは非常にありがたかったです。


Expoの導入

❶ アカウントの作成

https://expo.dev/ からアカウントを作成します。

expo.dev

❷ アプリの準備

iPhone、AndroidにExpo Goアプリをインストールして、自分のアカウントでログインします。

expo.dev

❸ expo-cliの導入

yarn add -D expo-cli

で、導入しました。
本当はグローバルにインストールした方が便利だとは思うのですが、いつもなるべくローカルに導入しています。

❹ expo-cliにログイン

package.jsonのscriptsに、

"scripts": {
  "login": "expo login"
}

と記載し、

yarn run login

でログインします。
僕はログインしてなかったので、通知を送ろうとした際に400が返ってきました。
参考) https://github.com/expo/expo/issues/9617


プッシュ通知を受信するアプリの作成

❶ プロジェクトの作成

package.jsonのscriptsに、

"scripts": {
  "init": "expo init"
}

と記載し、

yarn run init

でプロジェクトを作成します。
TypeScriptを使いたかったので、Managed workflowのblank(TypeScript)でプロジェクトを作りました。
そして、プロジェクト作成後、作成されたディレクトリの中身をトップにまるまる移動しています。

❷ expo-notificationsの導入

package.jsonのscriptsに、

"scripts": {
  "install-notifications": "expo install expo-notifications",
}

と記載し、

yarn run install-notifications

で、expo-notificationsを導入します。

❸ app.jsonを編集

app.jsonに、

"android": {
  "useNextNotificationsApi": true
}

を追記します。これを書いておかないと、Android端末でプッシュ通知をタップした際の処理が効かないです。

❹ コードを書く

App.tsx
import Constants from 'expo-constants';
import * as Notifications from 'expo-notifications';
import React, { useState, useEffect, useRef } from 'react';
import { Button, Linking, StyleSheet, View } from 'react-native';
import { Subscription } from 'expo-modules-core';

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center'
  },
});

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

async function registerForPushNotificationsAsync() {
  let token;

  if (Constants.isDevice) { // ❶
    const { status: existingStatus } = await Notifications.getPermissionsAsync();

    let finalStatus = existingStatus;

    if (existingStatus !== 'granted') {
      const { status } = await Notifications.requestPermissionsAsync();

      finalStatus = status;
    }

    if (finalStatus !== 'granted') {
      alert('Failed to get push token for push notification!');

      return;
    }

    try {
      token = (await Notifications.getExpoPushTokenAsync()).data;
    } catch (err) {
      console.error(err);
    }
  } else {
    alert('Must use physical device for Push Notifications');
  }

  return token;
}

export default function App() {
  const [ expoPushToken, setExpoPushToken ] = useState('');
  const notificationListener = useRef<Subscription>();
  const responseListener = useRef<Subscription>();

  useEffect(() => {
    registerForPushNotificationsAsync().then((token) => token && setExpoPushToken(token));

    responseListener.current = Notifications.addNotificationResponseReceivedListener((response) => { // ❷
      alert('yeah, yeah!');
      console.log(response);
    });

    return () => {
      notificationListener.current && Notifications.removeNotificationSubscription(notificationListener.current);
      responseListener.current && Notifications.removeNotificationSubscription(responseListener.current);
    };
  }, []);

  return (
    <View style={ styles.container }>
      <Button
        title="display token"
        onPress={() => { Linking.openURL(`https://kimizuka.fm?token=${ expoPushToken }`) }} // ❸
      />
      <Button
        title="clear badge"
        onPress={() => {{ Notifications.setBadgeCountAsync(0) }}}
      />
    </View>
  );
}

ほぼほぼ、ドキュメントのコードなのですが、TypeScriptで書いたので所々書き方が異なっています。
また、ローカルプッシュは必要なかったので、該当箇所は削除しました。

ポイント

  1. TypeScriptだと、Constants.isDeviceは非推奨と商事されるのですが、ドキュメント上は非推奨になっておらず、型とドキュメントのどちらが間違っているかわからない、かつ、代替手段がわからないのでそのまま使っています。
  2. 通知をタップした際の処理を書いていますが、Android端末では app.json"useNextNotificationsApi": true を記載しないと動作しません。
  3. tokenをコピペできるように、適当なURLのパラメーターにくっつけて外部ブラウザを起動するようにしてみました。

これでアプリは完成です。
挙動としては、

  • display tokenをタップすると外部ブラウザが開く(?token=以降にトークンが表示される)
  • プッシュ通知を受け取って、通知をタップすると「yeah, yeah!」と表示されるアプリです。
  • clear badgeをタップするとバッジが消える(のちにiOSのみの機能ということを知る)

を実装しました。
タップした時に表示する文字列を「yeah, yeah!」にした理由はのちに判明します。

次に通知送信用のコードを書きます。


プッシュ通知を送信するサーバの作成

❶ node-fetchの導入

yarn add -D node-fetch

で、node-fetchを導入します。

❷ トークンをメモする

作成したアプリの「display token」をタップして、トークンをメモします。

❸ コードを書く

push.mjs
import fetch from 'node-fetch'; // ❶

const tokens = [
  'ExponentPushToken[XXXXXX]',
  'ExponentPushToken[XXXXXX]'
];

tokens.forEach(async (token) => {
  const pushMessage = {
    to: token,
    sound: 'default',
    title: '本気で Push! Push!',
    badge: 0 | Math.random() * 9 + 1, // ❷
    body: 'We can say!',
    data: {
      timestamp: Date.now()
    }
  }

  const res = await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Accept-encoding': 'gzip, deflate',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(pushMessage)
  });

  console.log(res);
});

ポイント

  1. ファイル名を.mjsにすることでimportを使えるようにする 参考) https://blog.kimizuka.org/entry/2021/09/10/223310
  2. バッジに表示する数は1〜9をランダムで抽選する

という感じです。
これで、

node push.mjs

で、プッシュ通知が送れるようになりました。
タイトルに「本気で Push! Push!」、本文に「We can say!」、タップすると「yeah, yeah!」と表示されるはずです。

youtu.be


おわりに

当初の目標であった、「Expoでつくったアプリにローカルサーバからプッシュ通知を送ってアプリケーションバッジをつける」は、iOSアプリでしか達成できなかったのですが、Expoの導入から通知の受け取りまでは、iOS、Android共に達成できたので、まあ良しとしましょう。
開発用、本番用を分けて証明書を作るのが面倒だと思っていたのですが、Expoを使えば証明書を気にすることなく実装できることがわかったので、積極的に使っていきたいと思います。