- 概要・前提・注意点など
- 環境の確認
- Android: アプリの作成
- Android: Firebaseの設定
- Android: アプリにPush通知受信機能を実装
- Android: アプリにFirebaseからPushを送信
- Android: Firebaseの鍵ファイルを取得
- Android: アプリにPHPからPushを送信
- iOS: アプリの作成
- iOS: 証明書の作成
- iOS: アプリにPush通知受信機能を実装
- iOS: アプリにcurlからPushを送信
- iOS: アプリにPHPからPushを送信
- AmazonSNS: プッシュ通知用のキーや証明書の登録
- AmazonSNS: PHPプログラムの作成
- AmazonSNS: 動作確認
- iOS: 証明書の更新
- iOS: p8証明書
- アプリからHTTPリクエストする例
- アプリ起動時にデバイストークンをサーバ側に記録
- トラブル
- 考察: アプリのIDについて
- 考察: 処理の流れについて
- 考察: テーブル設計
- 考察: アプリ用API
- 考察: アプリ用プッシュ通知
- 考察: 本番公開用に作成する(上に整理する前の考察メモ)
- メモ
概要・前提・注意点など
■概要
AndroidとiOSにプッシュ通知を送信するためのメモ。
AndroidとiOSではプッシュ通知のためのプログラムは変わるものの、基本的な流れは同じ。
端末の「デバイストークン」とアプリの「バンドルID」をもとに、送信対象の「端末」と端末にインストールされている「アプリ」を特定する。
プッシュ通知の配信は、Androidでは「FCM(Firebase Cloud Messaging)」を、iOSでは「APNs(Apple Push Notification Service)」を使用し、
GoogleやAppleが提供しているAPIをプログラムから叩くことで実現する。
大量のプッシュ通知を安定して配信するのは難しい部分が多い。
よって配信は、最終的にAmazonSNSに任せるといい。
今回は、
1. Android単体でプッシュ通知を実装する。
2. iOS単体でプッシュ通知を実装する。
3. AmazonSNSから、AndroidとiOSの両方にプッシュ通知を送信する。
という流れで実装する。
■主な参考ページ
AWS SNSからAndroidにプッシュ通知するためにやったこと、ハマったこと - Qiita
https://qiita.com/nashitake/items/4724527c5c6fef4427d2
Android から Amazon SNS を使ってみる - Qiita
https://qiita.com/kusokamayarou/items/27e023ad06cade20c731
AndroidのPush通知の導入方法 - Qiita
https://qiita.com/YusukeYamazaki/items/9394234089b776bcb320
[2019年度版]AWS SNSでiOSのプッシュ通知 設定手順まとめ | カフーブログ
https://kahoo.blog/howto-aws-sns-ios-push-notification/
AWS SDK for PHP を用いた Amazon SNS の操作 - Qiita
https://qiita.com/gomi_ningen/items/1002d256285c6d72c6ac
Amazon SNSでプッシュ通知を送るための基礎知識 | UNITRUST
https://www.unitrust.co.jp/6182
以下は後発の記事なので、そのうち参考にしたい。
AmazonSNSを使ったpush通知の実装について #AWS - Qiita
https://qiita.com/Dai_Kentaro/items/5b17272207c66adcdbe4
■アプリのID
※iOSアプリではAppIDに、net.refirio.* のようなワイルドカードを利用することができ、複数のバンドルIDを作成する必要がなくなる。
ただしこの場合、バンドルIDをもとにアプリを特定する必要がある機能を利用できない。
プッシュ通知やアプリ内課金がこれにあたるので、今回はワイルドカードのまま使わない。
いったんアプリを公開すると変更できないので、慎重に決定したい。
「iOSとAndroidの両方で作る」「本番用と検収用と開発用がある」「Pushも使用する」などを考慮する。
現状の結論として、以下のようにするのが良さそう。
・iOSもAndroidも net.refirio.pushtest1 のようなIDにする。
・例えば開発版書き出し時には .dev を付ける。
つまり、具体的には以下のようなIDになる。
net.refirio.pushtest1 … 本番
net.refirio.pushtest1.stg … 検収
net.refirio.pushtest1.dev … 開発(ローカル環境など)
iOSとAndroidでIDを統一できるように、「pushtest1」部分にハイフンやアンダーバーは無い方が無難か。
(と思ったが、使える文字も異なるので無理にIDを統一する必要は無いかもしれない。
ただしURLや各種サービスのIDは、普段からできるだけ統一しておきたいので悩ましいところ。)
詳細はこのテキストの「考察: 本番公開用に作成する」も参考に。
■iOSのp8証明書
iOSではプッシュ通知の送信に証明書が必要になる。
p8証明書を使えば、証明書の定期的な更新が不要になる。
詳細は後述の「iOS: p8証明書」を参照。
環境の確認
サーバサイドの環境は、通常のLAMPでいい。
AmazonSNSのためにAWSのSDKを使用するので、PHPはその時点での最新版を使うことが推奨される。
DockerやVagrantで環境を構築することも可能。
ただしアプリからPHPにアクセスさせたい場合、当然ながら同一LAN内の他端末からアクセスできるようにしておく必要がある。
curlコマンドやPHPで通信を行うため、まずは環境を確認する。
PHP curlでHTTP/2リクエストを実行するための設定 on CentOS 7 | 稲葉サーバーデザイン
https://inaba-serverdesign.jp/blog/20171011/php_curl_http2_centos7.html
Amazon Linux 2 なら特別な更新作業なしにPHP+curlでHTTP/2リクエストを送信できた。
以下は2021年9月に構築した Amazon Linux 2。PHPはExtrasリポジトリからインストールしたもの。
$ cat /etc/system-release
Amazon Linux release 2 (Karoo)
$ openssl version
OpenSSL 1.0.2k-fips 26 Jan 2017
$ php --version
PHP 7.4.21 (cli) (built: Jul 7 2021 17:35:08) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
$ curl --version
curl 7.76.1 (x86_64-koji-linux-gnu) libcurl/7.76.1 OpenSSL/1.0.2k-fips zlib/1.2.7 libidn2/2.3.0 libssh2/1.4.3 nghttp2/1.41.0
Release-Date: 2021-04-14
Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtsp scp sftp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS GSS-API HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz Metalink NTLM NTLM_WB SPNEGO SSL UnixSockets
$ php -r 'phpinfo();' | grep SSL
SSL => Yes
MULTI_SSL => No
SSL Version => OpenSSL/1.0.2k-fips
core SSL => supported
extended SSL => supported
OpenSSL support => enabled
OpenSSL Library Version => OpenSSL 1.0.2k-fips 26 Jan 2017
OpenSSL Header Version => OpenSSL 1.0.2k 26 Jan 2017
Native OpenSSL support => enabled
OpenSSL => Stig Venaas, Wez Furlong, Sascha Kettler, Scott MacVicar
$ curl -vso /dev/null --http2 https://www.google.co.jp/
* Trying 142.250.207.3:443...
* Connected to www.google.co.jp (142.250.207.3) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* successfully set certificate verify locations:
* CAfile: /etc/pki/tls/certs/ca-bundle.crt
* CApath: none
* TLSv1.2 (OUT), TLS header, Certificate Status (22):
} [5 bytes data]
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
} [512 bytes data]
* TLSv1.2 (IN), TLS handshake, Server hello (2):
{ [96 bytes data]
* TLSv1.2 (IN), TLS handshake, Certificate (11):
{ [4009 bytes data]
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
{ [149 bytes data]
* TLSv1.2 (IN), TLS handshake, Server finished (14):
{ [4 bytes data]
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
} [70 bytes data]
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
} [1 bytes data]
* TLSv1.2 (OUT), TLS handshake, Finished (20):
} [16 bytes data]
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
{ [1 bytes data]
* TLSv1.2 (IN), TLS handshake, Finished (20):
{ [16 bytes data]
* SSL connection using TLSv1.2 / ECDHE-ECDSA-AES128-GCM-SHA256
* ALPN, server accepted to use h2
* Server certificate:
* subject: CN=*.google.co.jp
* start date: Aug 14 08:23:49 2023 GMT
* expire date: Nov 6 08:23:48 2023 GMT
* subjectAltName: host "www.google.co.jp" matched cert's "*.google.co.jp"
* issuer: C=US; O=Google Trust Services LLC; CN=GTS CA 1C3
* SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
} [5 bytes data]
* Using Stream ID: 1 (easy handle 0xc26600)
} [5 bytes data]
> GET / HTTP/2
> Host: www.google.co.jp
> user-agent: curl/7.76.1
> accept: */*
>
{ [5 bytes data]
< HTTP/2 200
< date: Fri, 15 Sep 2023 02:02:38 GMT
< expires: -1
< cache-control: private, max-age=0
< content-type: text/html; charset=Shift_JIS
< content-security-policy-report-only: object-src 'none';base-uri 'self';script-src 'nonce-DzUUpbjjm6zwKLvJsasAFA' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp
< p3p: CP="This is not a P3P policy! See g.co/p3phelp for more info."
< server: gws
< x-xss-protection: 0
< x-frame-options: SAMEORIGIN
< set-cookie: 1P_JAR=2023-09-15-02; expires=Sun, 15-Oct-2023 02:02:38 GMT; path=/; domain=.google.co.jp; Secure
< set-cookie: AEC=Ad49MVHajKAOpVrRPnIwK-msTAiUwUdGNpMKT8SiU444GjxwuDuApwL2r1s; expires=Wed, 13-Mar-2024 02:02:38 GMT; path=/; domain=.google.co.jp; Secure; HttpOnly; SameSite=lax
< set-cookie: NID=511=Mo9ACzb5BLQ6s--enshWZGJ_aJR9z59-J1ozzZNJWiSssDdTS7aihWx6tURtjsq3eGVjb_dk9RjUyAIEg_9R2R5wU5pxOJ8LNElZliyyHn8WNYgt7gk0Tc53ytT3dTvVGm6mrFv9GLgQoqP8bl9NX90MEIQKDpIh8QW4fSGWmEI; expires=Sat, 16-Mar-2024 02:02:38 GMT; path=/; domain=.google.co.jp; HttpOnly
< alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
< accept-ranges: none
< vary: Accept-Encoding
<
{ [5 bytes data]
* Connection #0 to host www.google.co.jp left intact
PHPプログラムからもcurlコマンドを実行できることを確認しておく。
(ファイルの文字コードは UTF-8N にする。)
$ cat curl_test.php
<?php
if (!defined('CURL_HTTP_VERSION_2_0')) {
define('CURL_HTTP_VERSION_2_0', CURL_HTTP_VERSION_1_1 + 1);
}
$url = 'https://www.google.co.jp/';
$opts = [
CURLOPT_VERBOSE => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_SSL_VERIFYPEER => false
];
$ch = curl_init($url);
curl_setopt_array($ch, $opts);
curl_exec($ch);
curl_close($ch);
以下のとおり実行できる。
Googleのページデータを取得できれば成功。
$ php curl_test.php
* Trying 142.250.207.3:443...
* Connected to www.google.co.jp (142.250.207.3) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* successfully set certificate verify locations:
* CAfile: /etc/pki/tls/certs/ca-bundle.crt
* CApath: none
* SSL connection using TLSv1.2 / ECDHE-ECDSA-AES128-GCM-SHA256
* ALPN, server accepted to use h2
* Server certificate:
* subject: CN=*.google.co.jp
* start date: Aug 14 08:23:49 2023 GMT
* expire date: Nov 6 08:23:48 2023 GMT
* issuer: C=US; O=Google Trust Services LLC; CN=GTS CA 1C3
* SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x5647b92d6360)
> GET / HTTP/2
Host: www.google.co.jp
accept: */*
< HTTP/2 200
< date: Fri, 15 Sep 2023 02:05:51 GMT
< expires: -1
< cache-control: private, max-age=0
< content-type: text/html; charset=Shift_JIS
< content-security-policy-report-only: object-src 'none';base-uri 'self';script-src 'nonce-24lYDgFlIXzIPGj39wOGtg' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp
< p3p: CP="This is not a P3P policy! See g.co/p3phelp for more info."
< server: gws
< x-xss-protection: 0
< x-frame-options: SAMEORIGIN
< set-cookie: 1P_JAR=2023-09-15-02; expires=Sun, 15-Oct-2023 02:05:51 GMT; path=/; domain=.google.co.jp; Secure
< set-cookie: AEC=Ad49MVF7GxHzcpVDA9KugVDkq5bfulVFD1MIxyXAX1eiBjjLbYVtTJQSbbg; expires=Wed, 13-Mar-2024 02:05:51 GMT; path=/; domain=.google.co.jp; Secure; HttpOnly; SameSite=lax
< set-cookie: NID=511=FIn9mttV5kF9-t52WJGMQ0HJ-Xv6yfwXkFsqSvpBpk0Zg5dSu352hgAPCiCUQwjogG8WOrHCjB7Z3R3dp7fBl8CSYlqJjMWYzPOR0tfzluNlpB_8fNyLBH-6WBN1x3W97kormoZ0U8BGfcwLR9Yc_vQsAxPrktYJOYbWpQHC2Ew; expires=Sat, 16-Mar-2024 02:05:51 GMT; path=/; domain=.google.co.jp; HttpOnly
< alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
< accept-ranges: none
< vary: Accept-Encoding
<
<!doctype html><html itemscope="" itemtype="http://schema.org/WebPage" lang="ja"><head>
〜〜中略
</body></html>* Connection #0 to host www.google.co.jp left intact
Android: アプリの作成
※いったんプッシュ通知機能の無い状態でアプリを作る。
Android Studioで「New Project」をクリック。
↓
「Empty Activity」が選択されているので、そのまま「Next」。
↓
Name: PushTest1
Package name: net.refirio.pushtest1 (名前をもとに自動入力される。)
Save location: C:\Users\refirio\AndroidStudioProjects\PushTest1 (アプリケーション名をもとに自動入力される。)
Mininum SDK: API 26: Android 8.0 (Oreo)(案件のターゲット層に応じて調整する。)
入力したら「Finish」。
エミュレータと実機で、アプリを起動できることを確認する。
Android: Firebaseの設定
■Firebaseプロジェクトの作成
公式サイト
https://firebase.google.com/
コンソール
https://console.firebase.google.com/
Firebaseのコンソールにアクセスし、「プロジェクトを追加」をクリック。
プロジェクト名: PushTest1
プロジェクトID: pushtest1-aeb26(プロジェクト名から自動で入力されるが、後から判りやすいように編集し直してもいい。)
「続行」をクリック。
「このプロジェクトで Google アナリティクスを有効にする」にチェックが入っていることを確認する。
「続行」をクリック。
アナリティクス用に既存のアカウントを選択するか、新しいアカウントを作成する。
(今回は開発用である「refirio」を選択した。「このアカウントに新しいプロパティを自動的に作成します」と表示された。)
「プロジェクトを作成」をクリック。
しばらく待つと「新しいプロジェクトの準備ができました」と表示される。
「続行」をクリック。
「アプリに Firebase を追加して利用を開始しましょう」という画面へ遷移する。
「開始するにはアプリを追加してください」の文言の上にある、Androidのアイコンをクリック。
「Android アプリに Firebase を追加」という画面へ遷移する。
※後から確認すると、アナリティクス側で「refirio」内に「pushtest1-aeb26」というプロパティが作成されていた。
またアプリ側で何かプログラムを書いたわけでも無いのに、「ユーザー」「イベント数」に反応がある。
最低限(アクティビティ単位でのアクセス数?)のことは自動で計測されているのか。
■Firebaseアプリの作成
「1 アプリの登録」という画面になる。
Android パッケージ名: net.refirio.pushtest1
アプリのニックネーム: PushTest1
デバッグ用の署名証明書 SHA-1: (空欄)
※まずはニックネーム「PushTest1 Dev」で開発版用に作るか…と思ったが、重複してAndroidパッケージ名を登録することができない。
つまり「net.refirio.pushtest1」用に作るのは「PushTest1」であり、これは本番環境用に使うべきものとなる。
iOSでは最初に開発版用にプッシュ通知証明書を作成しているが、これは「パッケージ名は本番用と同じだが、開発用に『Development SSL Certificate』を登録できる」なので、Androidとは少し性格が違うものだと思われる。
※後述の「考察: アプリのIDについて」も参照。
基本的には、プロジェクト自体を分ける方が良さそうではある。
※プロジェクト名には「android」という文字を含めておく方がいいか。
つまり「pushtest1-android」「pushtest1-android-stg」「pushtest1-android-dev」のような名前にするか。
ただし今回のように「iOSではFirebaseを使わない」「最終的にはAmazonSESから送信する」という場合、「android」という文字を含めなくても支障は無い。
FirebaseはGoogleのサービスなので、「もしiOS用に作ることがあれば『-ios』という接尾語を付ける」でいいかもしれない。
※某会社アプリの場合、「xxxxx」プロジェクトの中に「xxxxx-android」「xxxxx-android-stg」「xxxxx-android-dev」がある。
ただし、その後に作った別アプリは
「yyyyy」プロジェクトの中に「yyyyy」「yyyyy-dev」があり、
「yyyyy-staging」プロジェクトの中に「yyyyy」「yyyyy-dev」があり、
となっている。
上記を入力して「アプリを登録」をクリック。
「2 構成ファイルをダウンロードして追加する」という画面になる。
「google-services.json をダウンロード」をクリック。
表示される解説どおり、「Androidプロジェクトの新規作成」で作成したアプリの「app」直下にファイルを配置する。
(Android Studio で「Android」ではなく「Project」に表示を切り替え、「app」フォルダアイコンに対してドラッグ&ドロップで配置する。その後、また「Android」に戻しておく。)
配置できたら「次へ」をクリック。
「3 Firebase SDK の追加」という画面になる。
「Groovy(build.gradle)」を選択する。
プロジェクト直下の build.gradle と、「app」直下の build.gradle に指定のコードを追加する。
追加したら、AndroidStudioの画面上部に「sync now」が表示されるのでクリックする。
ビルドが完了されるまで待つ。完了したら「次へ」をクリック。
以上で完了。
「コンソールに進む」をクリック。
※同じプロジェクト内に同じアプリで再作成する場合、
「プロジェクトの設定 → PushTest1 → SDKの手順を確認する」から作業できる。
■参考
プロジェクト直下の build.gradle と、「app」直下の build.gradle に追加したコードは以下のとおり。
プロジェクト直下の build.gradle:
plugins {
〜略〜
id("com.google.gms.google-services") version "4.4.2" apply false … 追加
}
「app」直下の build.gradle:
plugins {
〜略〜
id("com.android.application") … 追加
id("com.google.gms.google-services") … 追加
}
dependencies {
〜略〜
implementation(platform("com.google.firebase:firebase-bom:33.5.0")) … 追加
implementation("com.google.firebase:firebase-analytics") … 追加
「id("com.android.application")」というのも記載されているが、これは「alias(libs.plugins.android.application)」があるので追加不要みたい。
(追加すると「すでに読み込まれている」のエラーになる。)
なお、ダウンロードした google-services.json には以下のような内容が記載されている。
{
"project_info": {
"project_number": "884881935685",
"project_id": "pushtest1-aeb26",
"storage_bucket": "pushtest1-aeb26.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:884881935685:android:cc453484c4825b2153022c",
"android_client_info": {
"package_name": "net.refirio.pushtest1"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDYzY_QZPAGTpm20Pa69ExMWrNbCQukz1o"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
Android: アプリにPush通知受信機能を実装
■app/build.gradle
dependencies {
〜略〜
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-messaging") … 追加
「firebase-messaging」の最新バージョンは以下で確認できるが、今は常に「com.google.firebase:firebase-messaging-ktx」で問題無さそう。(バージョン情報以外にも、参考にできそうな情報がある。)
ただし2024年10月時点で確認すると、デフォルトで「com.google.firebase:firebase-analytics-ktx」ではなく「com.google.firebase:firebase-analytics」が使われるようになっている。
これに合わせて、今回は「com.google.firebase:firebase-messaging-ktx」ではなく「com.google.firebase:firebase-messaging」にしている。
Android プロジェクトに Firebase を追加する | Firebase for Android
https://firebase.google.com/docs/android/setup?hl=ja
追加したら、AndroidStudioの画面上部に「sync now」が表示されるのでクリックする。
念のため、この時点でアプリを起動できることを確認しておく。
■res/drawable/ic_notification.webp
プッシュ通知用のアイコンを作成する。
今回は res/mipmap-mdpi/ic_launcher.webp を複製して名前を変更した。
★アイコンの形式やサイズについて、何が適切かは確認しておきたい。
また、アプリが起動中でもそうでなくても、プッシュ通知にアイコンが反映されるか確認する。
■res/values/colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
〜略〜
<color name="notification">#AAAAAA</color> … 追加
</resources>
※この色指定は必須のものか。
テキスト色を指定していないので、「ダークモードで表示されない」などは無いか。
省略してデフォルトの色を使ってくれるなら、それが良さそうだが。
…と思ったが、このアイコンと色の指定は、上記のとおりデフォルト値として指定しておくことが推奨されるらしい。
詳細は以下の記事を参照。
Android アプリでメッセージを受信する | Firebase Cloud Messaging
https://firebase.google.com/docs/cloud-messaging/android/receive?hl=ja
> 通知のデザインをカスタマイズするためのデフォルト値を設定することもおすすめします
> 通知ペイロード内にアイコンやカラーの値が設定されていない場合に適用される、カスタム デフォルト アイコンとカスタム デフォルト カラーを指定できます。
>
> Android では、以下に対してカスタム デフォルト アイコンが表示されます。
> ・Notifications Composer から送信されたすべての通知メッセージ。
> ・通知ペイロード内にアイコンが明示的に設定されていない通知メッセージ。
>
> Android では、以下に対してカスタム デフォルト カラーが使用されます。
> ・Notifications Composer から送信されたすべての通知メッセージ。
> ・通知ペイロード内にカラーが明示的に設定されていない通知メッセージ。
■app\src\main\AndroidManifest.xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> … Android13用に追加
<application
android:allowBackup="true"
〜略〜
tools:targetApi="31">
<meta-data … ブロックを追加
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification" />
<service … ブロックを追加
android:name=".MyFirebaseMessagingService"
android:exported="true">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<activity
android:name=".MainActivity"
■app\src\main\java\net\refirio\pushtest1\MyFirebaseMessagingService.kt
package net.refirio.pushtest1
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
class MyFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
Log.d("MyFirebaseMsgService", "showNotification Data Default: " + remoteMessage.data["default"])
// 通知の内容を取得して表示する
showNotification("通知タイトル", "通知メッセージ")
//showNotification("PushTest1", remoteMessage.data["default"])
}
private fun showNotification(title: String?, body: String?) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channelId = "notification"
val channel = NotificationChannel(
channelId,
"通知",
NotificationManager.IMPORTANCE_HIGH
)
notificationManager.createNotificationChannel(channel)
val intent = Intent(this, MainActivity::class.java)
.apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.drawable.ic_notification) // 通知アイコン
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent) // 通知をタップしたときの表示先
.setAutoCancel(true) // 通知をタップしたら消去
Log.d("MyFirebaseMsgService", "showNotification Title: $title")
Log.d("MyFirebaseMsgService", "showNotification Body: $body")
notificationManager.notify(0, notificationBuilder.build())
}
}
■app\src\main\java\net\refirio\pushtest1\MainActivity.kt
package net.refirio.pushtest1
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging
import net.refirio.pushtest1.ui.theme.PushTest1Theme
class MainActivity : ComponentActivity() {
private var tokenState: MutableState<String?>? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PushTest1Theme {
val localTokenState = remember { mutableStateOf<String?>(null) }
tokenState = localTokenState
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
MainScreen(modifier = Modifier.padding(innerPadding), localTokenState)
}
}
}
// Firebaseのトークンを取得
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("MainActivity", "Fetching FCM registration token failed", task.exception)
return@addOnCompleteListener
}
// トークンを取得
val token = task.result
Log.d("MainActivity", "FCM Token: $token")
tokenState?.value = token
}
}
}
fun checkAndRequestPermissions(
context: Context,
permissions: Array<String>,
launcher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
) {
if (
permissions.all {
ContextCompat.checkSelfPermission(
context,
it
) == PackageManager.PERMISSION_GRANTED
}
) {
// パーミッションが与えられている
Log.d("MainActivity", "Already granted")
} else {
// パーミッションを要求
launcher.launch(permissions)
}
}
@Composable
fun MainScreen(modifier: Modifier = Modifier, tokenState: MutableState<String?>) {
val context = LocalContext.current
val permissions = arrayOf(
Manifest.permission.POST_NOTIFICATIONS
)
val launcherMultiplePermissions = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissionsMap ->
val areGranted = permissionsMap.values.reduce { acc, next -> acc && next }
if (areGranted) {
// パーミッションが与えられている
Log.d("MainActivity", "Already granted")
} else {
// パーミッションを要求
Log.d("MainActivity", "Show dialog")
}
}
Column(modifier = modifier) {
Text("FCM Token: ${tokenState.value ?: "Loading..."}")
Button(
onClick = {
checkAndRequestPermissions(
context,
permissions,
launcherMultiplePermissions
)
}
) {
Text("通知を許可(Android13用)")
}
}
}
@Preview(showBackground = true)
@Composable
fun MainScreenPreview() {
var tokenState: MutableState<String?>? = null
val localTokenState = remember { mutableStateOf<String?>(null) }
tokenState = localTokenState
MainScreen(modifier = Modifier, localTokenState)
}
※実際の実装では、トークンを画面に表示することなく「デバイス内に保存してサーバにも投げる」としておけば良さそう。
後述の「アプリ起動時にデバイストークンをサーバ側に記録」も参照。
以下を参考にした。
2 Ways to Request Permissions in Jetpack Compose | by Igor Stevanovic | Better Programming
https://betterprogramming.pub/jetpack-compose-request-permissions-in-two-ways-fd81c4a702c
以下も参考にできるかもしれない。
AWS SNSからAndroidにプッシュ通知するためにやったこと、ハマったこと - Qiita
https://qiita.com/nashitake/items/4724527c5c6fef4427d2
Android 13で導入されるNotification runtime permissionについて調べてみた - Money Forward Developers Blog
https://moneyforward-dev.jp/entry/2022/04/11/android13-notification-runtime-permission/
【Androidで通知を出すために】通知の権限回りを整える
https://zenn.dev/tbsten/articles/droid-notification-permission
画面を表示したら通知の許可を求める…は、以下を参考にできそう。(未検証。)
Composableが表示されたタイミングでコルーチンを起動する(LaunchedEffect) | mokelab tech sheets
https://tech.mokelab.com/android/compose/effect/LaunchedEffect.html
■動作確認
アプリを実行すると、画面にデバイストークンが表示される。
また、ログにもデバイストークンが表示されるようにしている。
以下は実際に表示された値。
2024-10-24 18:54:27.967 13887-13887 MainActivity net.refirio.pushtest1 D FCM Token: envzgXQ9Tm0000000000-K:APA91bFp66znOyBVMEkFCBRXhh-ssW2iQeDnXXKDGy-Ey-cN-T9FlaXFk0eKHlvfeYtoOYQjhqFN3WayxCtFwhZnOm48Mx99LIiXD2x7YQwZ8wwYw-X756tTgRAB1QvGDbdZBcp7_x69
この文字列をもとに、サーバサイドプログラムからプッシュ通知を送信する。
具体的な送信方法は引き続き後述する。
※ログに表示されない場合、Logcatの左上で対象のデバイスが選択されているか確認する。
Android: アプリにFirebaseからPushを送信
Firebaseでプロジェクトのページを開く。
左メニュー → 実行 → Messaging → 新しいキャンペーンを送信 → 通知
通知のタイトル: テストのタイトル
通知テキスト: テストのテキスト
ターゲット: ユーザーセグメント
ターゲットとするユーザー: アプリ net.refirio.pushtest1
スケジュール: 現在
その他必要に応じて設定し、「確認」をクリックすると確認画面が表示されるので、「公開」をクリックすると送信できる。
数分間待っていると、以下のログが表示された。
2023-09-15 19:18:09.706 5883-8376 MyFirebaseMsgService net.refirio.pushtest1 D showNotification Title: プッシュ通知テスト
2023-09-15 19:18:09.706 5883-8376 MyFirebaseMsgService net.refirio.pushtest1 D showNotification Body: null
なお、この時点ではタイトルが「通知タイトル」、本文が「通知メッセージ」という内容で通知が表示される。
(ソースコード内で文言を固定しているため。)
※届くまで5分ほどかかるみたい?AmazonSNS経由なら比較的早く届くみたい?
AmazonSNSが色々良い感じに処理してくれているのかもしれない。
最終的にはAmazonSNSから送信するので、ここでは「時間はかかるが届くようだ」くらいまでの確認でいいか。
Jetpack Compose で Permission を要求する方法
https://zenn.dev/kaleidot725/articles/2021-11-13-jc-permission
Android: Firebaseの鍵ファイルを取得
■鍵ファイルとサーバーキーについて
Firebaseで対象のプロジェクトで、「プロジェクトの概要」の隣にある歯車アイコンから「プロジェクトの設定」をクリック。
設定画面に遷移するので、タブメニューから「Cloud Messaging」をクリック。
ここで「サーバーキー」の値を確認する…だったが、「Cloud Messaging API(レガシー)」と表示されて「無効」になっている。
これは、AWSから「[No Action Required] Reminder to register SMS Sender ID for Singapore」という件名で告知があったもの。
> このメッセージを受信しているのは、過去90日間にAmazon SNSを使用してアカウントから1つ以上のモバイルプッシュ通知を送信したためです。
> 2023年6月21日、Google は、レガシー Firebase Cloud Messaging (FCM) API を使用しているアプリケーションは、2024年6月20日以降、モバイルプッシュ通知を送信できなくなると発表しました。
> 弊社では、このお知らせを承知しており、Amazon SNS経由での通知送信機能が中断されないよう、代替手段の提供に取り組んでおります。
> 新しいFCM APIを使用できるようになりましたら、ご連絡いたします。
2024年6月1日以降は、サーバキーでのプッシュ通知送信ができなくなる。
サーバキーではなく鍵ファイルを発行して、それをもとにGoogle_clientを使って送信する…という方式に変更する必要がある。
ただし送信プログラムも複雑になるため、可能ならAmazonSNSを使っての送信にすると良さそう。
[アップデート] Amazon SNS の FCM を使ったプッシュ通知がトークンベースの HTTP v1 API をサポートしたので、レガシー FCM API から移行してみた | DevelopersIO
https://dev.classmethod.jp/articles/sns-fcm-http-v1-api-mobile-notifications/
■鍵ファイルの発行
[アップデート] Amazon SNS の FCM を使ったプッシュ通知がトークンベースの HTTP v1 API をサポートしたので、レガシー FCM API から移行してみた | DevelopersIO
https://dev.classmethod.jp/articles/sns-fcm-http-v1-api-mobile-notifications/
Firebaseで対象プロジェクト → プロジェクトの設定 → Cloud Messaging
「Cloud Messaging API」にサーバーキーが表示されていることを確認できる。
「Firebase Cloud Messaging API」には「サービス アカウントの管理」というリンクだけが表示されているのでクリック。
「プロジェクト「PushTest1」のサービス アカウント」という画面に遷移する。
一覧で「Firebase Admin SDK Service Agent」に対して「キーがありません」と表示されている列があるので、「… → 鍵を管理」をクリック。
鍵の作成画面に遷移するので、「鍵を追加 → 新しい鍵を作成」をクリック。
キーのタイプとして「JSON」と「P12」を選択できるが、今回は「JSON」を洗濯して「作成」をクリック。
pushtest1-aeb26-c00a0b10addc.json というファイルがダウンロードされた。
内容は以下のとおり。
{
"type": "service_account",
"project_id": "pushtest1-aeb26",
"private_key_id": "c00a0b10ad00000000000000000000baebe01a4f",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCnoXNCLluQ4ikO\nY7mxImNS9ft4LFWCXDUqkPajWgMYwJzyLd〜中略〜hx0RWLhN9\n7mZwLUZfLvTKeCalOeDx61eCn4cnHRhpIukGzV4NdF1KwD+v3Jpo2Ot74/pGwSWl\nKUYqj6EYTMsXropj5TFXGg0=\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-ik32h@pushtest1-aeb26.iam.gserviceaccount.com",
"client_id": "101260000000000064216",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-ik32h%40pushtest1-aeb26.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}
なお鍵を作成した後も、先の「Firebase Cloud Messaging API」画面では「サービス アカウントの管理」というリンクだけが表示されている状態だった。
■専用ツールから通知の送信
Firebase Cloud Messaging API(V1)でのPushテストの方法 #Android - Qiita
https://qiita.com/ko2ic/items/d4001a3d246b7146e838
上記ページを参考に試す。
まずは以下にアクセスする。
OAuth 2.0 Playground
https://developers.google.com/oauthplayground/
「OAuth 2.0 Playground」が表示される。
左の一覧から「Firebase Cloud Messaging API v1」をクリックし、
さらに表示される「https://www.googleapis.com/auth/cloud-platform」をクリック。
チェックが付いたら、画面下にある「Authorize APIs」ボタンをクリック。
アカウントの選択を求められるので、開発に使用するアカウントを選択し、「Google OAuth 2.0 Playground」からのリクエストを許可する。
画面の右に「Request / Response」として以下が表示された。
HTTP/1.1 302 Found
Location: https://accounts.google.com/o/oauth2/v2/auth?redirect_uri=https%3A%2F%2Fdevelopers.google.com%2Foaut...
GET /oauthplayground/?code=4/0AVG7fiSpC9M-LlpH2000000000000000000000000000000000cuotBvRbqzx5RSyZWMlw&scope=https://www.googleapis.com/auth/cloud-platform HTTP/1.1
Host: developers.google.com
次に画面の左で「Exchange authorization code for tokens」ボタンをクリック。
画面の右に「Request / Response」として以下が表示された。
HTTP/1.1 200 OK
Content-length: 481
X-xss-protection: 0
X-content-type-options: nosniff
Transfer-encoding: chunked
Expires: Mon, 01 Jan 1990 00:00:00 GMT
Vary: Origin, X-Origin, Referer
Server: scaffolding on HTTPServer2
-content-encoding: gzip
Pragma: no-cache
Cache-control: no-cache, no-store, max-age=0, must-revalidate
Date: Fri, 25 Oct 2024 03:25:23 GMT
X-frame-options: SAMEORIGIN
Alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-type: application/json; charset=utf-8
{
"access_token": "ya29.a0AeD0000000000VXi3KrmXMexiZPq67qwtcpmLbCzZHCvHT0DHtGzS4DNFyBZpH9NQIO5pN0EV_FztYD_JinxSChoUiwXfPO-aIpKTj5FTQFvAEH8jMDdMDmW-YyutWgvmu4AM4-YLi0HJx_yqQ_WKLV3Wv-W3Zof7yMNRq_kaCgYKARkSARESFQHGX2MikQE7hU03dobDnoDfwS7o9g0175",
"scope": "https://www.googleapis.com/auth/cloud-platform",
"token_type": "Bearer",
"expires_in": 3599,
"refresh_token": "1//04Me3Hh0000000000ARAAGAQSNwF-L9Ir_q5RPTr_SXCv-a3yVX1bTyYch64HuSahgu4iGX2KD1_qQCZmpmFiqytTd0FJagdgFr0"
}
次に画面の左で「HTTP Method」を「POST」にする。
さらに「Request URI」に以下を入力する。
https://fcm.googleapis.com/v1/projects/プロジェクトID/messages:send
今回の場合は以下のようになる。
https://fcm.googleapis.com/v1/projects/pushtest1-aeb26/messages:send
「Enter Request Body」をクリックすると、リクエスト内容の入力欄が表示される。
以下を入力する。(tokenの値には、送信先の端末のデバイストークンを設定する。以下はAndroid8に送信する場合。)
{
"message": {
"token": "envzgXQ9Tm0000000000-K:APA91bFp66znOyBVMEkFCBRXhh-ssW2iQeDnXXKDGy-Ey-cN-T9FlaXFk0eKHlvfeYtoOYQjhqFN3WayxCtFwhZnOm48Mx99LIiXD2x7YQwZ8wwYw-X756tTgRAB1QvGDbdZBcp7_x69",
"data": {
"body": "You Got A Notification",
"title": "You Got A Notification",
}
}
}
画面下の「close」で入力欄を閉じる。
画面左にある「Send the request」ボタンを押すと、プッシュ通知が届いた。
画面右に以下が表示された。
POST /v1/projects/pushtest1-aeb26/messages:send HTTP/1.1
Host: fcm.googleapis.com
Content-length: 331
Content-type: application/json
Authorization: Bearer ya29.a0AeD0000000000VXi3KrmXMexiZPq67qwtcpmLbCzZHCvHT0DHtGzS4DNFyBZpH9NQIO5pN0EV_FztYD_JinxSChoUiwXfPO-aIpKTj5FTQFvAEH8jMDdMDmW-YyutWgvmu4AM4-YLi0HJx_yqQ_WKLV3Wv-W3Zof7yMNRq_kaCgYKARkSARESFQHGX2MikQE7hU03dobDnoDfwS7o9g0175
{
"message": {
"token": "envzgXQ9Tm0000000000-K:APA91bFp66znOyBVMEkFCBRXhh-ssW2iQeDnXXKDGy-Ey-cN-T9FlaXFk0eKHlvfeYtoOYQjhqFN3WayxCtFwhZnOm48Mx99LIiXD2x7YQwZ8wwYw-X756tTgRAB1QvGDbdZBcp7_x69",
"data": {
"body": "You Got A Notification",
"title": "You Got A Notification",
}
}
}
HTTP/1.1 200 OK
Content-length: 86
X-xss-protection: 0
X-content-type-options: nosniff
Transfer-encoding: chunked
Vary: Origin, X-Origin, Referer
Server: scaffolding on HTTPServer2
-content-encoding: gzip
Cache-control: private
Date: Fri, 25 Oct 2024 03:31:12 GMT
X-frame-options: SAMEORIGIN
Alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-type: application/json; charset=UTF-8
{
"name": "projects/pushtest1-aeb26/messages/0:1729827072841922%d7f69f40f9fd7ecd"
}
■curlから通知の送信
「専用ツールから通知の送信」の手順中に取得したアクセストークンを使えば、curlコマンドでも送信することができる。
$ curl -X POST \
--header "Authorization: Bearer [アクセストークン]" \
--header "Content-Type: application/json" \
https://fcm.googleapis.com/v1/projects/[プロジェクトID]/messages:send -d " \
{ \"message\": {\"token\":\"[FCM_TOKEN]\",
\"data\":{\"body\": \"You Got A Notification\", \"title\": \"You Got A Notification\"}}}"
具体的には以下のようになる。
$ curl -X POST \
--header "Authorization: Bearer ya29.a0AeDClZAHyP3eyVXi3KrmXMexiZPq67qwtcpmLbCzZHCvHT0DHtGzS4DNFyBZpH9NQIO5pN0EV_FztYD_JinxSChoUiwXfPO-aIpKTj5FTQFvAEH8jMDdMDmW-YyutWgvmu4AM4-YLi0HJx_yqQ_WKLV3Wv-W3Zof7yMNRq_kaCgYKARkSARESFQHGX2MikQE7hU03dobDnoDfwS7o9g0175" \
--header "Content-Type: application/json" \
https://fcm.googleapis.com/v1/projects/pushtest1-aeb26/messages:send -d " \
{ \"message\": {\"token\":\"envzgXQ9Tm0000000000-K:APA91bFp66znOyBVMEkFCBRXhh-ssW2iQeDnXXKDGy-Ey-cN-T9FlaXFk0eKHlvfeYtoOYQjhqFN3WayxCtFwhZnOm48Mx99LIiXD2x7YQwZ8wwYw-X756tTgRAB1QvGDbdZBcp7_x69\",
\"data\":{\"body\": \"You Got A Notification\", \"title\": \"You Got A Notification\"}}}"
実行すると以下の結果が返され、プッシュ通知が届いた。
{
"name": "projects/pushtest1-aeb26/messages/0:1707468192965283%d7f69f40f9fd7ecd"
}
同じアクセストークンでさらにプッシュ通知を送信できるが、この値は一定期間で失効してしまう。
しばらくしてから試すと、以下のエラーが返されるようになった。
{
"error": {
"code": 401,
"message": "Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.",
"status": "UNAUTHENTICATED"
}
}
この場合、上に記載の手順で再度アクセストークンを取得すればいい。
Android: アプリにPHPからPushを送信
主に以下の記事を参考に進める。
PHP/LaravelでFCMpush通知を実装した件 #PHP - Qiita
https://qiita.com/takapon21/items/47a72472541650fe3b1a
以下のとおり、Composerを使えるようにする。(PHP7.4以上が必要。)
$ cd /path/to/firebase
$ composer
Composer version 2.4.4 2022-10-27 14:39:29
以下のとおり、必要ライブラリをインストールする。(バージョン指定は無しでいいかもしれない。)
$ composer require google/apiclient:^2.15.0
23 package suggestions were added by new dependencies, use `composer suggest` to see details.
Generating autoload files
6 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
No security vulnerability advisories found
続いて、Pushを送信するためのPHPプログラムを作成する。
pushtest1-aeb26-c00a0b10addc.json の情報をもとにして、以下のプログラムを作成する。(ファイルの文字コードは UTF-8N にする。)
<?php
require_once 'vendor/autoload.php';
$googleClient = new Google_client;
$googleClient->useApplicationDefaultCredentials();
// 認証情報
$googleClient->setAuthConfig([
'type' => 'service_account',
'project_id' => 'pushtest1-aeb26',
'private_key_id' => 'c00a0b10ad00000000000000000000baebe01a4f',
'private_key' => "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCnoXNCLluQ4ikO\nY7mxImNS9ft4LFWCXDUqkPajWgMYwJzyLd〜中略〜hx0RWLhN9\n7mZwLUZfLvTKeCalOeDx61eCn4cnHRhpIukGzV4NdF1KwD+v3Jpo2Ot74/pGwSWl\nKUYqj6EYTMsXropj5TFXGg0=\n-----END PRIVATE KEY-----\n",
'client_email' => 'firebase-adminsdk-ik32h@pushtest1-aeb26.iam.gserviceaccount.com',
'client_id' => '101260000000000064216',
'auth_uri' => 'https://accounts.google.com/o/oauth2/auth',
'token_uri' => 'https://oauth2.googleapis.com/token',
'auth_provider_x509_cert_url' => 'https://www.googleapis.com/oauth2/v1/certs',
'client_x509_cert_url' => 'https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-ik32h%40pushtest1-aeb26.iam.gserviceaccount.com',
]);
// FCMにアクセスするためのスコープ
$googleClient->addScope('https://www.googleapis.com/auth/firebase.messaging');
// 認証されたHTTPクライアントを取得
$httpClient = $googleClient->authorize();
// 送信対象と送信内容を作成
$project = 'https://fcm.googleapis.com/v1/projects/pushtest1-aeb26/messages:send';
$tokens = [
'envzgXQ9Tm0000000000-K:APA91bFp66znOyBVMEkFCBRXhh-ssW2iQeDnXXKDGy-Ey-cN-T9FlaXFk0eKHlvfeYtoOYQjhqFN3WayxCtFwhZnOm48Mx99LIiXD2x7YQwZ8wwYw-X756tTgRAB1QvGDbdZBcp7_x69',
];
$title = 'プッシュ通知のテスト';
$body = 'これはプッシュ通知のテストです。';
foreach ($tokens as $token) {
try {
// FCMにPOSTリクエストを送信
$response = $httpClient->post($project, [
'json' => [
'message' => [
'token' => $token,
'notification' => [
'title' => $title,
'body' => $body,
],
'apns' => [
'payload' => [
'aps' => [
'alert' => [
'title' => $title,
'body' => $body
]
]
]
],
"android" => [
"priority" => "high",
"notification" => [
"sound" => "default",
]
],
]
]
]);
// 応答の確認
if ($response->getStatusCode() == 200) {
echo "送信成功: Token = {$token}\n";
} else {
echo "送信失敗: Token = {$token}, HTTP ステータスコード = " . $response->getStatusCode() . "\n";
}
} catch (\GuzzleHttp\Exception\RequestException $e) {
// 通信エラーなどリクエスト送信時のエラー
if ($e->hasResponse()) {
$errorMessage = $e->getResponse()->getBody()->getContents();
echo "送信失敗: Token = {$token}, エラーメッセージ = {$errorMessage}\n";
} else {
echo "送信失敗: Token = {$token}, ネットワークエラーまたはその他のエラー\n";
}
} catch (\Exception $e) {
// その他のエラー
echo "送信失敗: Token = {$token}, エラーメッセージ = " . $e->getMessage() . "\n";
}
}
echo "Complete\n";
以下のとおり実行して、本番アプリにプッシュ通知を送信できることを確認できた。
$ php firebase.php
これなら、数日経ってもアクセストークンが失効されることは無かった。
一度に複数端末へ送信したい場合、上記のように送信先を配列で定義して、ループで順に送信するといい。
もしくは、FCMのトピックを使って一斉送信するといい。(未検証。)
なおPHP7環境だと、以下のエラーになるので注意。
Fatal error: Composer detected issues in your platform: Your Composer dependencies require a PHP version ">= 8.1.0". You are running 7.3.19. in /path/to/firebase/vendor/composer/platform_check.php on line 24
この場合、PHPを8にアップデートするか、別途PHP8.1をインストールして実行するといい。
$ /path/to/php81/php firebase.php
■トラブル事例1
Composer実行時に以下のエラーになった場合、
Problem 1
- Root composer.json requires google/apiclient 2.15.0 -> satisfiable by google/apiclient[v2.15.0].
- google/apiclient v2.15.0 requires php ^7.4|^8.0 -> your php version (7.3.19) does not satisfy that requirement.
一例だがWindows環境では、以下のようにPHP8.1を参照できるようにするといい。
C:\localhost\home\test\public_html\firebase\composer.bat
@ECHO OFF
C:\php81\php.exe "%~dp0composer.phar" %*
またPHP7.3環境では、「composer require google/apiclient」とバージョン指定なしならインストールできることを確認できた。
プッシュ通知の送信もできた。
■トラブル事例2
Composer実行時に以下のエラーになった場合、
The openssl extension is required for SSL/TLS protection but is not available.
If you can not enable the openssl extension, you can disable this error, at your own risk, by setting the 'disable-tls' option to true.
以下のページを参考にphp.iniを調整する。
composerのSSLのエラーを解決する
https://digirakuda.org/blog/2018/06/04/post-251/
■トラブル事例3
プッシュ通知送信時に以下のエラーになった場合、
PHP Warning: openssl_sign(): Supplied key param cannot be coerced into a private key in /path/to/firebase/vendor/firebase/php-jwt/src/JWT.php on line 254
Warning: openssl_sign(): Supplied key param cannot be coerced into a private key in /path/to/firebase/vendor/firebase/php-jwt/src/JWT.php on line 254
PHP Fatal error: Uncaught DomainException: OpenSSL unable to sign data in /path/to/firebase/vendor/firebase/php-jwt/src/JWT.php:256
鍵の指定が正しくできているか確認する。
鍵データには「\n」が含まれるので、正しく改行と解釈させるために、ダブルクォートでくくって指定する。
【Laravel/PHP】openssl_sign(): supplied key param cannot be coerced into a private key の 対応方法
https://engineer-jose-blog.com/%E3%80%90php%E3%80%91openssl_sign-supplied-key-param-cannot-be-coerce...
■AmazonSNSを使う準備
通知の動作を確認できたら、MyFirebaseMessagingService.kt にある以下の処理を修正しておく。(AmazonSNSからのメッセージを受け取る準備。)
★この修正を行なった後でも、問題無く通知を受け取れるみたい?
showNotification("通知タイトル", "通知メッセージ")
↓
showNotification("PushTest1", remoteMessage.data["default"])
※ひととおり試してから、上記の調整を行ったうえでプッシュ通知を再送信すると、普通にメッセージが表示された?気のせい?
Firebase側で良い感じに調整されたのか、別のアプリに届いたなど何かの勘違いか。
何度か試していると、「通知タイトル」「通知メッセージ」になったり、また「テストのタイトル」「テストのテキスト」に戻ったりするような?
※そもそも「FirebaseからPushを送信した場合、このキーで送られてくる」はあるはずなので、改めて調査したい。
後述の「AmazonSNS: PHPプログラムの作成」にある「補足」も参照。
iOS: アプリの作成
※いったんプッシュ通知機能の無い状態でアプリを作る。
※Organization Identifier を考慮して、いったん「pushtest1」のような Product Name で作成してIDを決めさせ、後からアプリの名前を変更するといい。
IDについては、「概要・前提・注意点など」の「アプリのID」も参照。
Xcodeでプロジェクトを作成する。
Application: App
Product Name: pushtest1
Team: (案件に応じて適切に選択する)
Organization Identifier: net.refirio
Interface: SwiftUI
Language: Swift
Bundle Identifier は、上記内容でアプリを作成すると「net.refirio.pushtest1」になる。
エミュレータと実機で、アプリを起動できるかテストする。
※以降の「証明書」は、断りが無ければ原則「p12証明書」を使った証明書のこと。
後発の「p8証明書」を使った手順は、後述の「iOS: p8証明書」を参照。
iOS: 証明書の作成
■Apple Developer Programへのログイン
ブラウザで以下にログインする。
Apple Developer Program
https://developer.apple.com/jp/programs/
右上のアカウント名が案件に応じたものになっていることを確認する。
■Push通知の使用を設定
Xcodeのプロジェクトの「Signing & Capabilities」で「+Capabilities」をクリックし、
一覧に表示される「Push Notifications」をダブルクリックで選択する。
(はじめて証明書を作成するときのみ。)
Apple Developer Program で
Certificates, Identifiers & Profiles → Identifiers
にアクセスして確認すると、一覧に
「XC jp refirio pushtest1 (net.refirio.pushtest1)」
が追加されている。クリックして「Push Notifications」が「Configurable」になっていることを確認する。
※Xcodeから登録されたAppIDは、名前に「XC」のプレフィックスが付く。(これはIDではなく名前なので、後から判りやすい名前に編集すればいいみたい。)
※プッシュ通知など特別な機能を使わない場合は基本的にワイルドカード扱いになり、Identifiersの一覧には表示されない。
■CSRを作成
Macでキーチェーンアクセスを起動。
メニューから
キーチェーンアクセス → 証明書アシスタント → 認証局に証明書を要求...
を実行。
ユーザのメールアドレス: refirio@example.com (自身のメールアドレス)
通称: refirio (日本語を含めると、AmazonSNSに登録できないので注意)
CAのメールアドレス: (空欄)
要求の処理: 「ディスクに保存」「鍵ペア情報を指定」にチェックを入れる
「続ける」
↓
保存場所を指定する
「保存」
↓
鍵のサイズ: 2048ビット
アルゴリズム: RSA
デフォルトで上記設定のはずなので「続ける」
↓
証明書要求がディスク上に作成されました
「完了」
デフォルトでは
CertificateSigningRequest.certSigningRequest
というファイル名で作成される。
これでCSRの作成は完了。引き続きCSRをAppleに登録する。
■プッシュ通知用の証明書を作成
ブラウザから Apple Developer Program の
Certificates, Identifiers & Profiles → Identifiers → XC jp refirio pushtest1 (net.refirio.pushtest1)
をクリック。
Push Notifications にある「Configure」をクリック。
さらに「Development SSL Certificate」の「Create Certificate」をクリック。
(いったん開発用だけでいい。本番用やアドホック用の場合は「Production SSL Certificate」の「Create Certificate」をクリック。)
↓
Create a New Certificate
作成したCSRファイルを選択する
「Continue」
↓
Download Your Certificate
「Download」をクリックして証明書をダウンロードする(aps_development.cer)
■プッシュ通知用のp12ファイルを作成
Macで証明書 aps_development.cer をダブルクリックして、キーチェーンに登録する。
(登録すると、キーチェーンアクセスの一覧に表示される。)
↓
登録された証明書を確認する。
キーチェーンアクセスの上部にある「分類」を「自分の証明書」にした状態で探す。
(証明書のダウンロードページにある「Expiration Date」をもとに「有効期限」と比較するといい。また、Finderで検索してから書き出そうとするとp12を選択できないので注意。)
証明書を選択して「ファイル → 書き出す」をクリック。
デフォルトで「証明書」というファイル名になるが、日本語ファイル名だとAWSへの登録に失敗するので変更する。
ここでは「PushTest1-Dev」として保存する(開発版用。本番用なら「PushTest1」などとする。)
「書き出した項目を保護するために使用されるパスワード」の入力画面になるが、パスワードはカラのまま「OK」をクリック。
「キーチェーンアクセスは、キーチェーンのキー○○を書き出そうとしています。」の入力画面になるが、Macのログインパスワードを入力して「許可」をクリック。
これで「PushTest1-Dev.p12」というファイルが作成される。
iOS: アプリにPush通知受信機能を実装
Xcodeのプロジェクトの「Signing & Capabilities」で「+Capabilities」をクリックし、一覧に表示される「Background Modes」をダブルクリックで選択する。
その後、画面に表示される「Remote notification」にチェックを入れる。
以下の記事に参考画面があるが、当時からUIは変更されている。
You've implemented -[ application: didReceiveRemoteNotification: fetchCompletionHandler:], but you still need to add "remote-notification" to the list of your supported UIBackgroundModes in your Info.plist.の解決法 - ゆーじのUnity開発日記
http://unity-yuji.xyz/youve-implemented-applicationdidreceiveremotenotification-remote-notification-...
プログラムは、主に以下の記事を参考に作成する。
SwiftUIでのプッシュ通知の最小構成 - すいすいSwift
https://swiswiswift.com/2022-03-03/
pushtest1App.swift
import SwiftUI
@main
struct pushtest1App: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate … 追加
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
ContentView.swift
import SwiftUI
struct ContentView: View { … ContentView内の処理を変更
let publisher = NotificationCenter.default.publisher(for: .deviceToken)
@State var deviceToken: String = ""
var body: some View {
VStack {
Text("DeviceToken")
TextField("Device Token is Empty", text: $deviceToken)
.padding()
}
.onReceive(publisher) { message in
if let deviceToken = message.object as? String {
self.deviceToken = deviceToken
print("Device Token: " + self.deviceToken)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
AppDelegate.swift(新規に作成する)
import UIKit
//@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Show Push Notification Dialogue
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
guard granted else { return }
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
UNUserNotificationCenter.current().delegate = self
return true
}
// MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
}
extension Notification.Name {
static let deviceToken = Notification.Name("DeviceToken")
}
extension AppDelegate: UNUserNotificationCenterDelegate {
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%.2hhx", $0) }.joined()
print("Device token: \(token)")
NotificationCenter.default.post(name: .deviceToken, object: token)
}
func application(_ application: UIApplication, didReceiveRemoteNotification payload: [AnyHashable: Any]) {
print(payload)
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.sound, .badge])
}
}
※plistの編集は不要だった。
※CocoaPodsのインストールは不要だった。
※実際の実装では、トークンを画面に表示することなく「デバイス内に保存してサーバにも投げる」としておけば良さそう。
後述の「アプリ起動時にデバイストークンをサーバ側に記録」も参照。
■動作確認
アプリを実行すると、「"〇〇"は通知を送信します。よろしいですか?」が表示されるので「許可」をタップする。
画面にデバイストークンが表示されることを確認する。
また、ログにもデバイストークンが表示されるようにしている。
以下は実際に表示された値。
Device Token: d6cb5af49500000000002425020838f4d4792c2de946bce49ad240be4f19c9b2
この文字列をもとに、サーバサイドプログラムからプッシュ通知を送信する。
具体的な送信方法は引き続き後述する。
※エミュレータではプッシュ通知を受け取れないので、実機で実行する。
iOS: アプリにcurlからPushを送信
■curlでプッシュ通知を送信(p.12)
iOSのPush通知でAPNsとの連携を証明書と認証キーでそれぞれやってみた - つばくろぐ @takamii228
https://takamii.hatenablog.com/entry/2020/07/13/190027
まずはcurlで送信する。(この場合、PHPは関係ない。)
以下にデータを送ることで、プッシュ通知を送信できる。
開発環境用 ... api.development.push.apple.com
本番環境用 ... api.push.apple.com
以下のコマンドでプッシュ通知を送信できる。(本番環境用の場合、送信先は api.push.apple.com にする。)
$ curl -v -d '{"aps":{"alert":{"title":"[送信タイトル]","body":"[送信メッセージ]"}}}' -H "Content-Type: application/json" -H "apns-topic: [アプリのID]" -H "apns-priority: 10" --http2 --cert-type P12 --cert [p12ファイル] https://api.development.push.apple.com/3/device/[デバイストークン]
具体的には、以下のように実行する。
$ curl -v -d '{"aps":{"alert":{"title":"テスト","body":"これはp12ファイルによる送信です。"}}}' -H "Content-Type: application/json" -H "apns-topic: net.refirio.pushtest1" -H "apns-priority: 10" --http2 --cert-type P12 --cert PushTest1-Dev.p12 https://api.development.push.apple.com/3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad2...
* Trying 17.188.143.34:443...
* Connected to api.development.push.apple.com (17.188.143.34) port 443
* ALPN: curl offers h2,http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* CAfile: /etc/pki/tls/certs/ca-bundle.crt
* CApath: none
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Request CERT (13):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Certificate (11):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS handshake, CERT verify (15):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN: server accepted h2
* Server certificate:
* subject: C=US; ST=California; O=Apple Inc.; CN=api.development.push.apple.com
* start date: Apr 24 18:16:30 2024 GMT
* expire date: Apr 10 00:00:00 2025 GMT
* subjectAltName: host "api.development.push.apple.com" matched cert's "api.development.push.apple.com"
* issuer: CN=Apple Public Server RSA CA 12 - G1; O=Apple Inc.; ST=California; C=US
* SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://api.development.push.apple.com/3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad2...
* [HTTP/2] [1] [:method: POST]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: api.development.push.apple.com]
* [HTTP/2] [1] [:path: /3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad240be4f19c9b2]
* [HTTP/2] [1] [user-agent: curl/8.3.0]
* [HTTP/2] [1] [accept: */*]
* [HTTP/2] [1] [content-type: application/json]
* [HTTP/2] [1] [apns-topic: net.refirio.pushtest1]
* [HTTP/2] [1] [apns-priority: 10]
* [HTTP/2] [1] [content-length: 97]
> POST /3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad240be4f19c9b2 HTTP/2
> Host: api.development.push.apple.com
> User-Agent: curl/8.3.0
> Accept: */*
> Content-Type: application/json
> apns-topic: net.refirio.pushtest1
> apns-priority: 10
> Content-Length: 97
>
< HTTP/2 200
< apns-id: CF6A16B6-0000-14A5-265B-4DC3C7E52CBE
< apns-unique-id: d29eefb2-0000-e6da-dbeb-4f7e7232e32a
<
* Connection #0 to host api.development.push.apple.com left intact
最後の部分に結果が表示されている。
以下は送信に成功したときの例。
* Connection state changed (MAX_CONCURRENT_STREAMS == 1000)!
* We are completely uploaded and fine
< HTTP/2 200
< apns-id: BD93B434-0000-2CEE-A4D7-B18540B741D6
< apns-unique-id: 65a06e7a-0000-59c0-0e8d-53d36e448d3b
<
* Connection #0 to host api.development.push.apple.com left intact
以下は不正なデバイストークンに送信したときの例。
* Connection state changed (MAX_CONCURRENT_STREAMS == 1000)!
* We are completely uploaded and fine
* Connection state changed (MAX_CONCURRENT_STREAMS == 1)!
< HTTP/2 400
< apns-id: BAAD2D41-0000-470D-87A6-399901FD299D
<
* Connection #0 to host api.development.push.apple.com left intact
{"reason":"BadDeviceToken"}
「HTTP/2 200」が返された場合、恐らく送信は成功している。
「HTTP/2 400」が返された場合、恐らく送信は失敗している。デバイストークンなどに間違いが無いか確認する。
証明書が不正だった場合、そもそも通信ができない。
このとき、以下のようなエラーが返された。
* Trying 17.188.168.149:443...
* Connected to api.development.push.apple.com (17.188.168.149) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* error reading PKCS12 file 'PushTest1-Dev.p12'
* Closing connection 0
curl: (58) error reading PKCS12 file 'PushTest1-Dev.p12'
「Content-Type」「apns-topic」「apns-priority」といったヘッダがあるが、どの解説も大文字はじまりと小文字はじまりが混在している。
「Content-Type」など一般的なものは大文字始まりで、「apns-topic」など独自のものは小文字始まりで…のような慣習があるのかもしれない。
Content-Type: HTTP エンティティー・ヘッダー - IBM Documentation
https://www.ibm.com/docs/ja/ibm-mq/7.5?topic=ssfksj-7-5-0-com-ibm-mq-ref-dev-doc-q110690--htm
priority: HTTP x-msg-priority エンティティー・ヘッダー - IBM Documentation
https://www.ibm.com/docs/ja/ibm-mq/7.5?topic=ssfksj-7-5-0-com-ibm-mq-ref-dev-doc-q110770--htm
iOSのPush通知でAPNsとの連携を証明書と認証キーでそれぞれやってみた - つばくろぐ @takamii228
https://takamii.hatenablog.com/entry/2020/07/13/190027
■pemファイルの作成
※p12ファイルをpemに変換し、それを使って送信することもできる。
※AmazonSNSにはpemではなくp12を登録して送信できるので原則この対応は不要だが、
後述の「iOS: アプリにPHPからPushを送信」ではpemを使用する。
opensslコマンドの使える環境で、作成した PushTest1-Dev.p12 をpem形式に変換する。
パスワードの入力を求められるが、p12ファイルをパスワードなしで作成した場合、パスワードは空欄のままEnterでいい。
$ openssl pkcs12 -in PushTest1-Dev.p12 -out PushTest1-Dev.pem -nodes -clcerts
Enter Import Password:
MAC verified OK
このファイルとデバイストークンを使って、直接プッシュ通知を送信できる。
■curlでプッシュ通知を送信(pem)
以下のコマンドでプッシュ通知を送信できる(curlで送信するならPHPは関係ない。本番環境用の場合、送信先は api.push.apple.com にする。)
$ curl -v -d '{"aps":{"alert":{"title":"[送信タイトル]","body":"[送信メッセージ]"}}}' -H "Content-Type: application/json" -H "apns-topic: [アプリのID]" -H "apns-priority: 10" --http2 --cert [pemファイル] https://api.development.push.apple.com/3/device/[デバイストークン]
具体的には、以下のようにする。
$ curl -v -d '{"aps":{"alert":{"title":"テスト","body":"これはpemファイルによる送信です。"}}}' -H "Content-Type: application/json" -H "apns-topic: net.refirio.pushtest1" -H "apns-priority: 10" --http2 --cert PushTest1-Dev.pem https://api.development.push.apple.com/3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad2...
* Trying 17.188.143.98:443...
* Connected to api.development.push.apple.com (17.188.143.98) port 443
* ALPN: curl offers h2,http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* CAfile: /etc/pki/tls/certs/ca-bundle.crt
* CApath: none
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Request CERT (13):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Certificate (11):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS handshake, CERT verify (15):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN: server accepted h2
* Server certificate:
* subject: C=US; ST=California; O=Apple Inc.; CN=api.development.push.apple.com
* start date: Apr 24 18:16:30 2024 GMT
* expire date: Apr 10 00:00:00 2025 GMT
* subjectAltName: host "api.development.push.apple.com" matched cert's "api.development.push.apple.com"
* issuer: CN=Apple Public Server RSA CA 12 - G1; O=Apple Inc.; ST=California; C=US
* SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://api.development.push.apple.com/3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad2...
* [HTTP/2] [1] [:method: POST]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: api.development.push.apple.com]
* [HTTP/2] [1] [:path: /3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad240be4f19c9b2]
* [HTTP/2] [1] [user-agent: curl/8.3.0]
* [HTTP/2] [1] [accept: */*]
* [HTTP/2] [1] [content-type: application/json]
* [HTTP/2] [1] [apns-topic: net.refirio.pushtest1]
* [HTTP/2] [1] [apns-priority: 10]
* [HTTP/2] [1] [content-length: 97]
> POST /3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad240be4f19c9b2 HTTP/2
> Host: api.development.push.apple.com
> User-Agent: curl/8.3.0
> Accept: */*
> Content-Type: application/json
> apns-topic: net.refirio.pushtest1
> apns-priority: 10
> Content-Length: 97
>
< HTTP/2 200
< apns-id: A4105CBB-0000-CD3D-ED0C-59DB4F06D2F2
< apns-unique-id: 12cd37b1-0000-95f2-90b8-1a24cce2f645
<
* Connection #0 to host api.development.push.apple.com left intact
「HTTP/2 200」が返された場合、恐らく送信は成功している。
「HTTP/2 400」が返された場合、恐らく送信は失敗している。デバイストークンなどに間違いが無いか確認する。
証明書が不正だった場合、そもそも通信ができない。
このとき、以下のようなエラーが返された。
* Trying 17.188.168.149:443...
* Connected to api.development.push.apple.com (17.188.168.149) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* could not load PEM client certificate, OpenSSL error error:0906D06C:PEM routines:PEM_read_bio:no start line, (no key found, wrong pass phrase, or wrong file format?)
* Closing connection 0
curl: (58) could not load PEM client certificate, OpenSSL error error:0906D06C:PEM routines:PEM_read_bio:no start line, (no key found, wrong pass phrase, or wrong file format?)
PHPプログラムからの送信は、引き続き次の項目にて。
iOS: アプリにPHPからPushを送信
APNs Provider API(HTTP/2)をPHPで試してみる - Qiita
https://qiita.com/itosho/items/2402df4de85b360d5bd9
■プログラムの作成
Pushを送信するためのPHPプログラムを作成する。
(ファイルの文字コードは UTF-8N にする。本番環境用の場合、送信先は api.push.apple.com にする。)
apns_test.php
<?php
if (defined('CURL_HTTP_VERSION_2_0')) {
$ch = curl_init('https://api.development.push.apple.com/3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad240be4f19c9b2');
curl_setopt($ch, CURLOPT_POSTFIELDS, '{"aps":{"alert":{"title":"テスト","body":"これはPHPからの送信です。"}}}');
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json','apns-topic: net.refirio.pushtest1','apns-priority: 10']);
curl_setopt($ch, CURLOPT_SSLCERT, 'PushTest1-Dev.pem');
//curl_setopt($ch, CURLOPT_SSLCERTPASSWD, 'your pem secret'); // pemファイルにパスワードを設定している場合
$response = curl_exec($ch);
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
var_dump($response);
var_dump($httpcode);
}
以下で実行できる。
(PushTest1-Dev.pem は同じ階層に配置してあるものとする。)
$ php apns_test.php
bool(true)
int(200)
送信内容に問題があると、JSON形式でエラー内容を返してくれる。
以下はデバイストークンを間違えた場合の例。
$ php apns_test.php
{"reason":"BadDeviceToken"}bool(true)
int(400)
不正な証明書だと、そもそもエラー内容が返されない。
以下のような結果になる。
$ php apns_test.php
bool(false)
int(0)
AmazonSNS: プッシュ通知用のキーや証明書の登録
■概要
AndroidとiOSにプッシュ通知を送信できるようになったら、AmazonSNSから一括して送信できるようにする。
プッシュ通知を送信するための鍵ファイルや証明書を、AmazonSNSに登録することで可能になる。
以下で「アプリケーション(Android)」「アプリケーション(iOS)」「トピック」を作成するが、大まかに
・Androidの場合、端末のデバイストークンを「アプリケーション(Android)」に追加すると、デバイストークンに対応したエンドポイントが発行される。
・iOSの場合、端末のデバイストークンを「アプリケーション(iOS)」に追加すると、デバイストークンに対応したエンドポイントが発行される。
・AndroidもiOSも、AmazonSNS経由ではエンドポイントを指定してプッシュ通知を送信できる。
・エンドポイントは「トピック」に追加できる。
・「トピック」を指定してプッシュ通知を送信すると、容易に一斉送信を実現できる。
という関係になっている。
なお、上記の操作はAWSコンソールから手動で行えるが、SDKを使用してプログラムから行なうこともできる。
■AmazonSNSアプリケーションの作成(Android)
Amazon SNS → プッシュ通知 →プラットフォームアプリケーションの作成
アプリケーション名: PushTest1-FCM-Dev(開発版想定。本番なら「PushTest1-FCM」などとする。iOS用にも作る可能性があるので、単に「PushTest1」ではなく「FCM」の文字を含めておくのが無難そう。)
プッシュ通知プラットフォーム: Firebase Cloud Messaging (FCM)
認証方法: トークン
Service Json: (「Android: Firebaseの鍵ファイルを取得」で取得したJsonファイル。)
★この時点では「Dev」を付けない方がいいような。(上の手順では本番用に作成しているので。)
「アプリケーションエンドポイントの作成」をクリック。
アプリケーション一覧に追加されたことを確認する。
■AmazonSNSアプリケーションの作成(iOS)
Amazon SNS → プッシュ通知 →プラットフォームアプリケーションの作成
アプリケーション名: PushTest1-APNS-Dev(開発版想定。本番なら「PushTest1-APNS」などとする。Android用にも作る可能性があるので、単に「PushTest1」ではなく「APNS」の文字を含めておくのが無難そう。)
プッシュ通知プラットフォーム: Apple iOS/VoIP/Mac
サンドボックスでの開発に使用されます: 開発用(Development SSL Certificate)の場合、チェックを入れる。
プッシュサービス: iOS
認証方法: 証明書(p8ファイルの場合は「トークン」にする。詳細は後述の「iOS: p8証明書」を参照。)
証明書: PushTest1-Dev.p12(先の手順で作成したファイル。)
パスワードの入力: (p12ファイル作成時、パスワードをカラで作成したなら空欄。)
「認証情報をファイルから読み込み」をクリック。
証明書の情報が表示されたことを確認して「アプリケーションエンドポイントの作成」をクリック。
※「認証情報をファイルから読み込み」をクリックしたとき。
「Apple の認証情報をファイルから読み込んでいるときにエラーが発生しました」
というエラーになる場合、p12のファイル名に日本語を含めていないか、証明書作成時に通称に日本語を含めていないか、などを確認する。
その他、原則として半角英数字に統一しておくほうが無難。
※Apple Developer Program での作業は、MacのSafariで行うことが推奨されているみたい。
どうしても意図した操作ができなければ、MacのSafariで試す。
※証明書が壊れていないかなどは、以下のコマンドで確認できる。
パスワード入力後、ファイルの情報が表示されることを確認する。
$ openssl pkcs12 -in PushTest1-Dev.p12 -info -noout
Enter Import Password:
MAC Iteration 1
MAC verified OK
PKCS7 Encrypted data: pbeWithSHA1And40BitRC2-CBC, Iteration 2048
Certificate bag
PKCS7 Data
Shrouded Keybag: pbeWithSHA1And3-KeyTripleDES-CBC, Iteration 2048
OpenSSL
https://sehermitage.web.fc2.com/crypto/openssl.html
PKCS #12 個人情報交換ファイルフォーマットについて - Qiita
https://qiita.com/kunichiko/items/3e2ec27928a95630a73a
APNsで使うp12形式の証明書、秘密鍵からpem形式の証明書、公開鍵を作成する方法がかなりわかりづらいのでまとめてみました - lineocean.com
https://lineocean.com/2017/10/31/499/
■AmazonSNSトピックの作成(すべてのデバイスへ一斉送信するトピックを作成する場合)
トピックを作成してそこに端末を登録しておけば、
トピックを指定するだけでトピックに属する端末すべてにプッシュ通知を送信できる。
左メニューから「トピック」を開く。
「トピックの作成」ボタンを押すとトピックの作成画面が開く。
タイプ: スタンダード
名前: PushTest1-Dev-All
表示名: (SMS用の項目なので空欄)
「トピックの作成」をクリック。
トピック一覧に追加されたことを確認する。
(表示されるARNの値は、後ほどPHPプログラムに設定する。)
トピックのARNを指定するだけで一斉送信ができるので、運用の際は「全端末配信用」のトピックを作っておくと良さそう。
後からトピックを追加すると、トピックに対して端末を登録する必要があるので注意。
トピックを気軽に増減する設計は避けるほうがいいかもしれない。要検証。
■アクセスキーの作成
画面右上のアカウント名 → セキュリティ認証情報 → ユーザー → ユーザーを追加
ユーザー名: pushtest1-dev
AWSマネジメントコンソールへのユーザーアクセス: 提供しない
ポリシー: AmazonSNSFullAccess
以下のとおりアクセスキーを発行。
ユースケース: コマンドラインインターフェイス (CLI)
Access key ID: XXXXXXXXXX
Secret access key: YYYYYYYYYY
AmazonSNS: PHPプログラムの作成
■ライブラリの準備
AWSのSDKを使ってプッシュ通知を送信できる。
Composerを使う場合、以下でインストールできる。
$ composer require aws/aws-sdk-php
もしくは以下からSDKを入手して、手動で配置することもできる。
https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/getting-started_installation.html
■プログラムの作成
AmazonSNSを扱うためのPHPプログラムを作成する。(ファイルの文字コードは UTF-8N にする。)
アクセスキーは、先ほど作成したものを使用する。
※いったん pushtest1-dev などのフォルダを作って、その中に開発版用として作成するといい。
証明書などが異なるので、本番用を作るなら pushtest1 フォルダなどに、検収版を作るなら pushtest1-stg フォルダなどに、別途作ると良さそう。
(実案件なら、そもそも配置するサーバ自体が異なると思われるが。)
アプリからPHPプログラムを呼び出す場合、SSL経由にする必要があるので注意。
(このプログラムはブラウザから操作する前提なので問題ないが、本番用のアプリなら端末からPHPにアクセスしてデバイストークンを渡したり…が必要。)
<?php
require 'vendor/autoload.php';
use Aws\Sns\SnsClient;
use Aws\Sns\Exception\SnsException;
$result_code = null;
$result_data = null;
try {
// AmazonSNSに接続
$client = new SnsClient([
'credentials' => [
'key' => 'XXXXXXXXXX',
'secret' => 'YYYYYYYYYY',
],
'region' => 'ap-northeast-1',
'version' => 'latest',
]);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['api'])) {
if ($_POST['api'] === 'createPlatformEndpoint') {
// アプリケーションに端末を追加
// https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sns-2010-03-31.html#createplatformendpoint
$result = $client->createPlatformEndpoint([
'PlatformApplicationArn' => $_POST['applicationArn'],
'Token' => $_POST['deviceToken'],
]);
$result_code = $result->get('@metadata')['statusCode'];
$result_data = $result['EndpointArn'];
} elseif ($_POST['api'] === 'listEndpointsByPlatformApplication') {
// アプリケーションに対するエンドポイントの一覧を取得
// https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sns-2010-03-31.html#listendpointsbyplatformapplic...
$result = $client->listEndpointsByPlatformApplication([
'NextToken' => $_POST['nextToken'],
'PlatformApplicationArn' => $_POST['applicationArn'],
]);
$result_code = $result->get('@metadata')['statusCode'];
$result_data = $result['Endpoints'];
} elseif ($_POST['api'] === 'getEndpointAttributes') {
// アプリケーションに対するエンドポイントの状態を取得
// https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sns-2010-03-31.html#getendpointattributes
$result = $client->getEndpointAttributes([
'EndpointArn' => $_POST['endpointArn'],
]);
$result_code = $result->get('@metadata')['statusCode'];
$result_data = $result['Attributes'];
} elseif ($_POST['api'] === 'setEndpointAttributes') {
// アプリケーションに対するエンドポイントの状態を変更
// https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sns-2010-03-31.html#setendpointattributes
$result = $client->setEndpointAttributes([
'Attributes' => [
'Enabled' => $_POST['enabled']
],
'EndpointArn' => $_POST['endpointArn'],
]);
$result_code = $result->get('@metadata')['statusCode'];
$result_data = [];
} elseif ($_POST['api'] === 'createTopic') {
// トピックを追加
// https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sns-2010-03-31.html#createtopic
$result = $client->createTopic([
'Name' => $_POST['name'],
]);
$result_code = $result->get('@metadata')['statusCode'];
$result_data = $result['TopicArn'];
} elseif ($_POST['api'] === 'subscribe') {
// トピックにエンドポイントを追加
// https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sns-2010-03-31.html#subscribe
$result = $client->subscribe([
'Endpoint' => $_POST['endpoint'],
'Protocol' => 'application',
'ReturnSubscriptionArn' => false,
'TopicArn' => $_POST['topicArn'],
]);
$result_code = $result->get('@metadata')['statusCode'];
$result_data = $result['SubscriptionArn'];
} elseif ($_POST['api'] === 'listTopics') {
// トピック一覧を取得
// https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sns-2010-03-31.html#listtopics
$result = $client->listTopics([
'NextToken' => $_POST['nextToken'],
]);
$result_code = $result->get('@metadata')['statusCode'];
$result_data = $result['Topics'];
} elseif ($_POST['api'] === 'publish') {
// 指定した端末もしくはトピックに対してプッシュ通知を送信
// https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sns-2010-03-31.html#publish
$gcm = json_encode([
'data' => [
'title' => $_POST['message'],
'body' => $_POST['message'],
'url' => null,
],
// 通知タップ時にアプリを開くために必要な追加のフィールド
'notification' => [
'title' => $_POST['message'],
'body' => $_POST['message'],
],
]);
$apns = json_encode([
'aps' => [
'alert' => [
'title' => $_POST['message'],
'body' => $_POST['message'],
],
'badge' => 1,
'sound' => 'default'
],
'url' => null
]);
$message = [
'default' => $_POST['message'],
'GCM' => $gcm,
'APNS' => $apns,
'APNS_SANDBOX' => $apns,
];
$parameter = [
'Message' => json_encode($message),
'MessageStructure' => 'json',
];
/*
$parameter = [
'Message' => $_POST['message'],
];
*/
if ($_POST['targetArn'] != '') {
$parameter['TargetArn'] = $_POST['targetArn'];
} elseif ($_POST['topicArn'] != '') {
$parameter['TopicArn'] = $_POST['topicArn'];
}
$result = $client->publish($parameter);
$result_code = $result->get('@metadata')['statusCode'];
$result_data = $result['MessageId'];
}
}
} catch (SnsException $e) {
exit('SnsException: ' . $e->getMessage());
} catch (Exception $e) {
exit('Exception: ' . $e->getMessage());
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AmazonSNS</title>
</head>
<body>
<h1>AmazonSNS</h1>
<?php if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['api'])) : ?>
<h2>Result</h2>
<pre><?php print_r(['result_code' => $result_code, 'result_data' => $result_data]) ?></pre>
<?php endif ?>
<h2>CreatePlatformEndpoint</h2>
<p>アプリケーションに端末を追加。</p>
<form action="sns.php" method="post">
<input type="hidden" name="api" value="createPlatformEndpoint">
<dl>
<dt>applicationArn(必須)</dt>
<dd><input type="text" size="60" name="applicationArn"></dd>
<dt>deviceToken(必須)</dt>
<dd><input type="text" size="60" name="deviceToken"></dd>
</dl>
<p><input type="submit" value="実行"></p>
</form>
<h2>ListEndpointsByPlatformApplication</h2>
<p>アプリケーションに対するエンドポイントの一覧を取得。(1回のリクエストで100件まで。1秒間に30トランザクションまで。)</p>
<form action="sns.php" method="post">
<input type="hidden" name="api" value="listEndpointsByPlatformApplication">
<dl>
<dt>nextToken</dt>
<dd><input type="text" size="60" name="nextToken" value=""></dd>
<dt>applicationArn(必須)</dt>
<dd><input type="text" size="60" name="applicationArn" value=""></dd>
</dl>
<p><input type="submit" value="実行"></p>
</form>
<h2>GetEndpointAttributes</h2>
<p>アプリケーションに対するエンドポイントの状態を取得。</p>
<form action="sns.php" method="post">
<input type="hidden" name="api" value="getEndpointAttributes">
<dl>
<dt>endpointArn(必須)</dt>
<dd><input type="text" size="60" name="endpointArn"></dd>
</dl>
<p><input type="submit" value="実行"></p>
</form>
<h2>SetEndpointAttributes</h2>
<p>アプリケーションに対するエンドポイントの状態を変更。</p>
<form action="sns.php" method="post">
<input type="hidden" name="api" value="setEndpointAttributes">
<dl>
<dt>enabled(必須)</dt>
<dd>
<select name="enabled">
<option value=""></option>
<option value="true">true</option>
<option value="false">false</option>
</select>
</dd>
<dt>endpointArn(必須)</dt>
<dd><input type="text" size="60" name="endpointArn"></dd>
</dl>
<p><input type="submit" value="実行"></p>
</form>
<h2>CreateTopic</h2>
<p>トピックを追加。</p>
<form action="sns.php" method="post">
<input type="hidden" name="api" value="createTopic">
<dl>
<dt>name(必須)</dt>
<dd><input type="text" size="60" name="name"></dd>
</dl>
<p><input type="submit" value="実行"></p>
</form>
<h2>Subscribe</h2>
<p>トピックにエンドポイントを追加。</p>
<form action="sns.php" method="post">
<input type="hidden" name="api" value="subscribe">
<dl>
<dt>endpoint(必須)</dt>
<dd><input type="text" size="60" name="endpoint" value=""></dd>
<dt>topicArn(必須)</dt>
<dd><input type="text" size="60" name="topicArn" value=""></dd>
</dl>
<p><input type="submit" value="実行"></p>
</form>
<h2>ListTopics</h2>
<p>トピック一覧を取得。(1回のリクエストで100件まで。1秒間に30トランザクションまで。)</p>
<form action="sns.php" method="post">
<input type="hidden" name="api" value="listTopics">
<dl>
<dt>nextToken</dt>
<dd><input type="text" size="60" name="nextToken" value=""></dd>
</dl>
<p><input type="submit" value="実行"></p>
</form>
<h2>Publish</h2>
<p>指定した端末もしくはトピックに対してプッシュ通知を送信。</p>
<form action="sns.php" method="post">
<input type="hidden" name="api" value="publish">
<dl>
<dt>message(必須)</dt>
<dd><input type="text" size="60" name="message" value=""></dd>
<dt>targetArn</dt>
<dd><input type="text" size="60" name="targetArn" value=""></dd>
<dt>topicArn</dt>
<dd><input type="text" size="60" name="topicArn" value=""></dd>
</dl>
<p><input type="submit" value="実行"></p>
</form>
</body>
</html>
■補足
プッシュ通知送信部分の $parameter を組み立てる部分を以下のように変更すると、単純なメッセージ以外も送信できる。
ただしAndroidではプッシュ通知の本文が表示されなくなるので、Androidアプリ側で受け取り処理の調整が必要になるみたい。(要検証。)
$fcm = json_encode([
'data' => [
'message' => $_POST['message'],
'param1' => 'xxx',
'param2' => 'yyy'
],
]);
$apns = json_encode([
'aps' => [
//'alert' => $_POST['message'],
'alert' => [
//'title' => 'タイトル',
//'subtitle' => 'サブタイトル',
'body' => $_POST['message'],
],
'badge' => 0,
'sound' => 'default'
],
'param1' => 'xxx',
'param2' => 'yyy'
]);
$message = [
'default' => $_POST['message'],
'FCM' => $fcm,
'APNS' => $apns,
'APNS_SANDBOX' => $apns,
];
$parameter = [
'Message' => json_encode($message),
'MessageStructure' => 'json',
];
/*
$parameter = [
'Message' => $_POST['message'],
];
*/
Androidアプリ側では MyFirebaseMessagingService.kt の以下の部分の調整で取得できるかも。(要検証。)
override fun onMessageReceived(remoteMessage: RemoteMessage?) {
Log.d(TAG, "From: " + remoteMessage!!.from!!)
// Check if message contains a data payload.
if (remoteMessage.getData().isNotEmpty()) {
Log.d(TAG, "Message data payload: " + remoteMessage.getData().get("default"))
// 10秒以上処理にかかる場合は、Firebase Job Dispatcherを使用する
sendNotification(this, remoteMessage.getData().get("default"))
}
// Check if message contains a notification payload.
if (remoteMessage.notification != null) {
Log.d(TAG, "Message Notification Body: " + remoteMessage.notification!!.body!!)
sendNotification(this, remoteMessage.notification!!.body!!)
}
}
以下のようにdefaultをmessageに変更すると、メッセージを受け取ることができるかも。(要検証。)
プッシュ通知一覧でもテキストが表示されるかも。(要検証。)
if (remoteMessage.getData().isNotEmpty()) {
Log.d(TAG, "Message data payload1: " + remoteMessage.getData().toString())
Log.d(TAG, "Message data payload2: " + remoteMessage.getData().get("message"))
// 10秒以上処理にかかる場合は、Firebase Job Dispatcherを使用する
sendNotification(this, remoteMessage.getData().get("message"))
}
AmazonSNS: 動作確認
■端末ごとに送信(Android)
Amazon SNS → プッシュ通知 → PushTest1-FCM-Dev
この画面で、「このアプリケーションにはエンドポイントがありません。」と表示されていることを確認する。
作成したPHPプログラムにアクセスし、
「CreatePlatformEndpoint」で以下を入力して実行する。
applicationArn: arn:aws:sns:ap-northeast-1:949004901725:app/GCM/PushTest1-FCM-Dev
deviceToken: (プッシュ通知を送信したいAndroid端末のデバイストークン。)
先ほどの画面に、エンドポイントが追加されていることを確認する。
トークンに対応したARNが作成され、AmazonSNSでプッシュ通知を送るときはこのARNを使用する。
作成したPHPプログラムにアクセスし、
「Publish」で以下を入力して実行する。
message: 任意のメッセージ(日本語可)を入力。
targetArn: (プッシュ通知を送信したいAndroid端末のARN。)
topicArn: (空欄)
端末にプッシュ通知が届くことを確認する。
(AmazonSNS経由なら、iOS・Androidとも端末に即座に届いた。)
なお、無効になったエンドポイントに送信した場合、以下のエラーが表示される。
これをもとに無効な送信先を削除したりはできそう。
またそれとは別に、アプリを起動するたびにデバイストークンの更新を行うと良さそう。(その時点でのデバイストークンを、AmazonSNSに上書き登録する。)
Error executing "Publish" on "https://sns.ap-northeast-1.amazonaws.com"; AWS HTTP error: Client error: `POST https://sns.ap-northeast-1.amazonaws.com` resulted in a `400 Bad Request` response:
<ErrorResponse xmlns="http://sns.amazonaws.com/doc/2010-03-31/">
<Error>
<Type>Sender</Type>
<Code>EndpointDis (truncated...)
EndpointDisabled (client): Endpoint is disabled - <ErrorResponse xmlns="http://sns.amazonaws.com/doc/2010-03-31/">
<Error>
<Type>Sender</Type>
<Code>EndpointDisabled</Code>
<Message>Endpoint is disabled</Message>
</Error>
<RequestId>2175f167-ac6a-55fe-9458-a5405b37d703</RequestId>
</ErrorResponse>
■端末ごとに送信(iOS)
Androidのときのように、「CreatePlatformEndpoint」で以下を入力して実行する。
applicationArn: arn:aws:sns:ap-northeast-1:949004901725:app/APNS_SANDBOX/PushTest1-APNS-Dev
deviceToken: (プッシュ通知を送信したいiOSのデバイストークン)
Androidのときのように、「Publish」で以下を入力して実行する。
message: 任意のメッセージ(日本語可)を入力
targetArn: (プッシュ通知を送信したいiOSのARN)
端末にプッシュ通知が届くことを確認する。
■トピックごとに送信
Amazon SNS → トピック → 作成したトピックの名前をクリック
この画面で、エンドポイントがカラであることを確認する。
作成したPHPプログラムにアクセスし、
「Subscribe」でトピックのARNと追加したい端末のARNを入力して実行する。(トピックにARNが追加される。)
「Publish」で以下を入力して実行する。
message: 任意のメッセージ(日本語可)を入力。
targetArn: (空欄)
topicArn: (プッシュ通知を送信したいトピックのARN。)
※トピック内に無効なエンドポイントが含まれていても、特にエラーなどは返してくれないみたい?
要検証。
■AmazonSNSから送信(デバッグ用)
Amazon SNS → プッシュ通知 → 作成したアプリケーションの名前をクリック
送信先にチェックを入れて「メッセージの発行」をクリック。
メッセージ構造: すべての配信プロトコルに同一のペイロード
メッセージ: AmazonSNSから直接送信
「メッセージの発行」をクリックすると送信できる。
また、「メッセージ構造」を「配信プロトコルごとにカスタムペイロード」にすると、
メッセージに以下のようなテキストが表示される。
「Sample message for iOS development endpoints」の部分を任意のメッセージに変更して送信でき、単純なメッセージ以外も送信できるようになる。
{
"APNS_SANDBOX": "{\"aps\":{\"alert\":\"AmazonSNSから直接送信\"}}"
}
Amazon SNS → トピック
送信先にチェックを入れて「メッセージの発行」をクリック。
とすれば、トピックに対しても送信できる。
iOS: 証明書の更新
※p12証明書は定期的な更新が必要。p8証明書にすれば更新は不要になる。
詳細は後述の「iOS: p8証明書」を参照。
iOSへプッシュ通知を送信するための証明書は、一年に一回更新する必要がある。
基本的には再度「iOS: 証明書の作成」を行えばいい。
作業の際
Certificates, Identifiers & Profiles → Identifiers → XC jp refirio pushtest1 (net.refirio.pushtest1)
で「Push Notifications」にある「Edit」をクリックすると、
登録済みの証明書の情報が「Development SSL Certificate」と「Production SSL Certificate」のそれぞれに表示される。
更新したい方の証明書で「Create Certificate」ボタンを押せば、新しく証明書を作成する画面に遷移できる。
この作業によって今使っている証明書と差し替えることになるが、もし即座に古い方の証明書が失効されてしまうと、プッシュ通知が届かなくなる。
これを防ぐためだと思われるが、証明書は2つを並行して登録できるようになっている。
(古い方の期限が切れたら、自動的に新しい方だけが使われるようになる。)
サーバからプッシュ通知を送信する際、当然ながら新しく作成した証明書を使用する。
AWS SNSのiOS向けPush通知(APNs)証明書の更新手順 - Qiita
https://qiita.com/b_a_a_d_o/items/e3bf9cd52b6cd9252088
■AmazonSNSへの登録
Amazon SNS → プッシュ通知 → 対象のアプリケーションを選択
作業前に「Apple の証明書の有効期限」を確認しておく。
「編集」をクリック。
「プッシュ証明書タイプ」を「iOSプッシュ証明書」にする。
「ファイルの選択」からp12ファイルをアップロードする。
アップロードできたら「認証情報をファイルから読み込み」をクリックし、証明書とプライベートキーが表示されることを確認する。
「変更の保存」をクリック
作業が完了したら、「Apple の証明書の有効期限」が更新されていることを確認する。
iOS: p8証明書
p8証明書については、以下で解説されている。
iOSでのPush通知について | 株式会社ウイングドア
https://wingdoor.co.jp/blog/ios%E3%81%A7%E3%81%AEpush%E9%80%9A%E7%9F%A5%E3%81%AB%E3%81%A4%E3%81%84%E...
以下のように紹介されている。
・Apple Developer Programで登録しているApple IDで管理している全てのアプリで使用可能。
・本番環境と開発環境で同じ証明書を使用できる。
・有効期限は無期限。
・証明書は一度しかダウンロードできない。(再度作成することは可能。)
・一つのApple IDで2つまでしか証明書を作成できない。
p8証明書を使うと、Androidと同じく「永続的なキーが発行されるが、使用するには都度トークンを発行する必要がある」となる。
トークンの発行については、AmazonSNSを使うことで容易に扱えるようになる。
p12の方が証明書の影響範囲が限定的というメリットはあるので、必ずしもp8証明書にしなければならないわけでは無い。
ただしAppleとしてはp8証明書を推奨しているようなので、まずはp8証明書の利用を検討するといい。
Should we use p8 Auth Key or P12 for APNs? | by Kieu Van Phuoc | Medium
https://kieuvanphuoc.medium.com/should-we-use-p8-auth-key-or-p12-for-apns-e5737938fde9
Apple Developer Programの「Keys」からキーを作成できるみたい。
curlで実際にプッシュ通知を送信するコードも以下で紹介されている。
iOSのPush通知でAPNsとの連携を証明書と認証キーでそれぞれやってみた - つばくろぐ @takamii228
https://takamii.hatenablog.com/entry/2020/07/13/190027
コマンド(curl)でPushテストする方法(iOS) #Swift - Qiita
https://qiita.com/dolfalf/items/2b65c77d11c4e8dbdd9a
以下なども参考になりそう。
iOSのプッシュ通知がp8認証キーに対応しました - ニフクラ mobile backend(mBaaS)お役立ちブログ
https://blog.mbaas.nifcloud.com/entry/2021/04/08/202417
iOS プッシュ通知の実装に必要な「p12形式の証明書」と「 p8形式の鍵」について #iOS - Qiita
https://qiita.com/kokogento/items/405703f3177ebd2e0320
APNs証明書(p8形式)の発行方法 - FANSHIPサポートガイド
https://support.fanship.jp/hc/ja/articles/900006538803-APNs%E8%A8%BC%E6%98%8E%E6%9B%B8-p8%E5%BD%A2%E...
■証明書の取得
APNs証明書(p8形式)の発行方法 - FANSHIPサポートガイド
https://support.fanship.jp/hc/ja/articles/900006538803-APNs%E8%A8%BC%E6%98%8E%E6%9B%B8-p8%E5%BD%A2%E...
iOS プッシュ通知の実装に必要な「p12形式の証明書」と「 p8形式の鍵」について #Firebase - Qiita
https://qiita.com/kokogento/items/405703f3177ebd2e0320
Certificates, Identifiers & Profiles → Keys → +
Key Nameを「refirio」とし、さらに下の一覧にある「Apple Push Notifications service (APNs)」にチェックを入れた。
キーは「1アカウントにつき2つまでしか作れない」「案件ごと、アプリごと、本番&検収環境、などで分けることができない」なので、単純に「refirio」という名前にした。
「Continue」をクリック。
確認画面が表示されるので「Register」をクリック。
鍵のダウンロードができるので「Download」をクリックして保存。(「AuthKey_XXXXXXXXXX.p8」という名前だった。)
さらに「Done」をクリックして完了。
Keysの画面に戻り、発行済みのキーとして以下が表示された。
KEY ID: XXXXXXXXXX
SERVICES: 1
NAME: refirio
Apple Developer Programにログインし、「メンバーシップの詳細」から「チームID」を確認しておく。
チームID: YYYYYYYYYY
■JWTトークンの発行
JWT(JSON Web Token)を使った認証を行う。
(Androidでも google/apiclient の内部では同様の仕組みが使われているみたい。)
JWTについては以下のページなどを参照。
初心者向けJWT講座:JSON Web Tokenを使った認証の仕組み
https://zenn.dev/collabostyle/articles/b08c7f29a2e94c
まずは、Composerで必要なライブラリをインストール。
$ composer require firebase/php-jwt
以下のとおりプログラムを作成。(今回は firebase-jwt.php としておく。)
<?php
require 'vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// 必要な情報を設定
$keyId = 'XXXXXXXXXX'; // Apple DeveloperのKey ID
$teamId = 'YYYYYYYYYY'; // Apple DeveloperのTeam ID
$p8FilePath = 'AuthKey_XXXXXXXXXX.p8'; // p8ファイルのパス
// p8証明書の読み込み
$privateKey = file_get_contents($p8FilePath);
if (!$privateKey) {
die('p8ファイルが見つかりません。');
}
// JWTのヘッダーとペイロードを設定
$now = time();
$payload = [
'iss' => $teamId, // チームID
'iat' => $now // JWTの発行時刻
];
$headers = [
'alg' => 'ES256', // アルゴリズム
'kid' => $keyId // キーID
];
// JWTトークンの生成
$jwt = JWT::encode($payload, $privateKey, 'ES256', $keyId);
echo "JWTトークン: " . $jwt . PHP_EOL;
プログラムを実行すると、以下のとおりJWTトークンが発行された。
$ php firebase-jwt.php
JWTトークン: eyJ0eXAiOi0000000000bGciOiJFUzI1NiIsImtpZCI6Ikc5MjNOWTRXNVkifQ.eyJpc3MiOiJENVpRUVk5OFpMIiwiaWF0IjoxNzI5ODUwOTMzfQ.kySqsqu5SkhS0WhKWmgou3SUf7188n3AHRvlK1_72aMlw6n3m019pk0cEVQuypx2M8nUr8U4K1cf5rO7Br9zIw
このJWTトークンを使って、以下のコマンドでプッシュ通知を送信できる。
$ curl -v -d '{"aps":{"alert":{"title":"[送信タイトル]","body":"[送信メッセージ]"},"sound":"default"}}' -H "Content-Type: application/json" -H "apns-topic: [アプリのID]" -H "apns-priority: 10" -H "authorization: bearer [JWTトークン]" --http2 https://api.development.push.apple.com/3/device/[デバイストークン]
具体的には、以下のように実行する。
$ curl -v -d '{"aps":{"alert":{"title":"テスト","body":"これはp8ファイルによる送信です。"},"sound":"default"}}' -H "Content-Type: application/json" -H "apns-topic: net.refirio.pushtest1" -H "apns-priority: 10" -H "authorization: bearer eyJ0eXAiOi0000000000bGciOiJFUzI1NiIsImtpZCI6Ikc5MjNOWTRXNVkifQ.eyJpc3MiOiJENVpRUVk5OFpMIiwiaWF0IjoxNzI5ODUwOTMzfQ.kySqsqu5SkhS0WhKWmgou3SUf7188n3AHRvlK1_72aMlw6n3m019pk0cEVQuypx2M8nUr8U4K1cf5rO7Br9zIw" --http2 https://api.development.push.apple.com/3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad2...
* Trying 17.188.143.66:443...
* Connected to api.development.push.apple.com (17.188.143.66) port 443
* ALPN: curl offers h2,http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* CAfile: /etc/pki/tls/certs/ca-bundle.crt
* CApath: none
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Request CERT (13):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Certificate (11):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN: server accepted h2
* Server certificate:
* subject: C=US; ST=California; O=Apple Inc.; CN=api.development.push.apple.com
* start date: Apr 24 18:16:30 2024 GMT
* expire date: Apr 10 00:00:00 2025 GMT
* subjectAltName: host "api.development.push.apple.com" matched cert's "api.development.push.apple.com"
* issuer: CN=Apple Public Server RSA CA 12 - G1; O=Apple Inc.; ST=California; C=US
* SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://api.development.push.apple.com/3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad2...
* [HTTP/2] [1] [:method: POST]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: api.development.push.apple.com]
* [HTTP/2] [1] [:path: /3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad240be4f19c9b2]
* [HTTP/2] [1] [user-agent: curl/8.3.0]
* [HTTP/2] [1] [accept: */*]
* [HTTP/2] [1] [content-type: application/json]
* [HTTP/2] [1] [apns-topic: net.refirio.pushtest1]
* [HTTP/2] [1] [apns-priority: 10]
* [HTTP/2] [1] [authorization: bearer eyJ0eXAiOi0000000000bGciOiJFUzI1NiIsImtpZCI6Ikc5MjNOWTRXNVkifQ.eyJpc3MiOiJENVpRUVk5OFpMIiwiaWF0IjoxNzI5ODUwOTMzfQ.kySqsqu5SkhS0WhKWmgou3SUf7188n3AHRvlK1_72aMlw6n3m019pk0cEVQuypx2M8nUr8U4K1cf5rO7Br9zIw]
* [HTTP/2] [1] [content-length: 114]
> POST /3/device/d6cb5af49500000000002425020838f4d4792c2de946bce49ad240be4f19c9b2 HTTP/2
> Host: api.development.push.apple.com
> User-Agent: curl/8.3.0
> Accept: */*
> Content-Type: application/json
> apns-topic: net.refirio.pushtest1
> apns-priority: 10
> authorization: bearer eyJ0eXAiOi0000000000bGciOiJFUzI1NiIsImtpZCI6Ikc5MjNOWTRXNVkifQ.eyJpc3MiOiJENVpRUVk5OFpMIiwiaWF0IjoxNzI5ODUwOTMzfQ.kySqsqu5SkhS0WhKWmgou3SUf7188n3AHRvlK1_72aMlw6n3m019pk0cEVQuypx2M8nUr8U4K1cf5rO7Br9zIw
> Content-Length: 114
>
< HTTP/2 200
< apns-id: 5371130C-0127-0D02-AA12-41C29AA8EE5B
< apns-unique-id: 7def9a2f-df7f-e8fc-778c-04337c567bf9
<
* Connection #0 to host api.development.push.apple.com left intact
■AmazonSNSへの登録
AmazonSNS → プッシュ通知 → PushTest1-APNS-Dev → 編集
認証方法: トークン
署名キー: (上の手順で取得したp8ファイル。)
署名キーID: XXXXXXXXXX
チームID: YYYYYYYYYY
バンドルID: net.refirio.pushtest1
「変更の保存」ボタンをクリック。
PushTest1-APNS-Dev の画面に戻る。
しばらくしてページを再読み込みすると、
認証方法: Certificate
証明書の有効期限: 2025-11-24T04:45:55Z
と表示されていた部分が
認証方法: Token
AppleバンドルID: net.refirio.pushtest1
AppleチームID: YYYYYYYYYY
と表示されるようになった。
この状態でAmazonSNS経由でプッシュ通知を送り、アプリに届くことを確認する。
アプリからHTTPリクエストする例
ここまでの例では、デバイストークンをPHPに渡すために手動での作業が必要となっている。
ただし実際のアプリでは、アプリからHTTPリクエストでPHPに渡すことになる。
以下、アプリからHTTPリクエストする例。
■PHP
test.php
<?php
header('Content-Type: application/json; charset=utf-8');
echo json_encode(array(
'status' => 'OK',
'datetime' => date('Y/m/d H:i:s'),
));
exit;
■Android(Kotlin)
※「OkHttp」を使うといいかもしれない。以下に検証メモがある。
Dropbox\技術\AndroidStudio.txt
※以下は「OkHttp」を使わずに実装する例。
Kotlin: HTTP GET/POST サンプルコード(AsyncTask) | UBUNIFU INCORPORATED
https://jp.ubunifu.co/development/kotlin-http-get-post-sample-code
[Android] 非同期処理 AsyncTaskの使い方
https://akira-watson.com/android/asynctask.html
[android開発] AndroidManifest.xmlの設定一覧(ネットワーク系) - 行け!偏差値40プログラマー
http://hensa40.cutegirl.jp/archives/6501
まずは汎用HTTPリクエストクラスを作成する。
MainActivity.kt と同じ階層に HttpTask.kt を作成して以下を記述。
パッケージ名はプロジェクトに合わせて調整する。
package net.refirio.pushtest1
import android.os.AsyncTask
import android.util.Log
import java.io.*
import java.net.HttpURLConnection
import java.net.URL
class HttpTask(callback: (String?) -> Unit) : AsyncTask<String, Unit, String>() {
var callback = callback
override fun doInBackground(vararg params: String): String? {
val url = URL(params[1])
val httpClient = url.openConnection() as HttpURLConnection
httpClient.setReadTimeout(10 * 1000)
httpClient.setConnectTimeout(10 * 1000)
httpClient.requestMethod = params[0]
if (params[0] == "POST") {
httpClient.instanceFollowRedirects = false
httpClient.doOutput = true
httpClient.doInput = true
httpClient.useCaches = false
httpClient.setRequestProperty("Content-Type", "application/json; charset=utf-8")
}
try {
if (params[0] == "POST") {
httpClient.connect()
val os = httpClient.getOutputStream()
val writer = BufferedWriter(OutputStreamWriter(os, "UTF-8"))
writer.write(params[2])
writer.flush()
writer.close()
os.close()
}
if (httpClient.responseCode == HttpURLConnection.HTTP_OK) {
val stream = BufferedInputStream(httpClient.inputStream)
val data: String = readStream(inputStream = stream)
return data
} else {
Log.d("HttpTask", "ERROR: ${httpClient.responseCode}")
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
httpClient.disconnect()
}
return null
}
fun readStream(inputStream: BufferedInputStream): String {
val bufferedReader = BufferedReader(InputStreamReader(inputStream))
val stringBuilder = StringBuilder()
bufferedReader.forEachLine { stringBuilder.append(it) }
return stringBuilder.toString()
}
override fun onPostExecute(result: String?) {
super.onPostExecute(result)
callback(result)
}
}
MainActivity.kt の onCreate 内に以下を記述。
// HTTPリクエストのテスト
HttpTask({
if (it == null) {
Log.d("MainActivity", "Data is empty.")
return@HttpTask
}
Log.d("MainActivity", "OK: ${it}")
val json = JSONObject(it)
val status = json.getString("status")
val datetime = json.getString("datetime")
Log.d("MainActivity", "status: ${status}")
Log.d("MainActivity", "datetime: ${datetime}")
}).execute("GET", "https://example.com/test.php")
インターネットにアクセスするため、AndroidManifest.xml に以下を追加。
「<manifest>」の直下に追加すればいい。
<uses-permission android:name="android.permission.INTERNET" />
■iOS(Swift)
ViewController.swift の viewDidLoad 内に以下を記述。
// HTTPリクエストのテスト
if let url = URL(string: "https://example.com/test.php") {
let request = URLRequest(url: url)
let task = URLSession.shared.dataTask(with: request, completionHandler: {
(data: Data?, response: URLResponse?, error: Error?) in
guard let data = data else {
print("Data is empty.")
return
}
do {
let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments)
if let status = (json as AnyObject).object(forKey: "status") {
print(status)
}
if let datetime = (json as AnyObject).object(forKey: "datetime") {
print(datetime)
}
} catch {
print("JSON parse error.")
}
print("Complete.")
})
task.resume()
}
アプリ起動時にデバイストークンをサーバ側に記録
「アプリからHTTPリクエストする例」の内容も踏まえて、アプリ起動時にデバイストークンをサーバ側に記録する例。
Androidについては、ChatGPTが「OkHttp」ではなく「Volley」を使ったコードを提示してきたので、いったんそのまま採用している。
Androidの通信ライブラリの歴史を振り返る #Java - Qiita
https://qiita.com/Reyurnible/items/33049c293c70bd9924ee
※実際の実装では、さらに「デバイストークン」「サーバ側で発行した一意なID」をデバイス内に保存しておくなどの処理もあると良さそう
■サーバサイド(save_device_token.php)
<?php
header('Content-Type: application/json; charset=utf-8');
if (file_put_contents('log/' . date('YmdHis') . '.log', print_r($_POST, true)) === false) {
echo json_encode(array(
'status' => 'NG',
));
} else {
echo json_encode(array(
'status' => 'OK',
'datetime' => date('Y/m/d H:i:s'),
));
}
exit;
■アプリ(Android)
package net.refirio.pushtest1
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.content.ContextCompat
import com.android.volley.Request
import com.android.volley.toolbox.StringRequest
import com.android.volley.toolbox.Volley
import com.google.firebase.messaging.FirebaseMessaging
import net.refirio.pushtest1.ui.theme.PushTest1Theme
class MainActivity : ComponentActivity() {
private var tokenState: MutableState<String?>? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PushTest1Theme {
val localTokenState = remember { mutableStateOf<String?>(null) }
tokenState = localTokenState
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
MainScreen(modifier = Modifier.padding(innerPadding), localTokenState)
}
}
}
// Firebaseのトークンを取得
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("MainActivity", "Fetching FCM registration token failed", task.exception)
return@addOnCompleteListener
}
// トークンを取得
val token = task.result
Log.d("MainActivity", "FCM Token: $token")
tokenState?.value = token
// サーバにトークンを送信
sendDeviceToken(token)
}
}
private fun sendDeviceToken(token: String) {
val url = "https://example.com/app/save_device_token.php"
// URLエンコード形式のデータを作成
val postBody = "device_token=${java.net.URLEncoder.encode(token, "UTF-8")}"
// リクエストを送信
val request = object : StringRequest(
Request.Method.POST,
url,
{ response ->
// 成功時の処理
Log.d("MainActivity", "Token saved successfully: $response")
},
{ error ->
// エラー時の処理
Log.e("MainActivity", "Failed to save token: $error")
}
) {
override fun getHeaders(): Map<String, String> {
val headers = HashMap<String, String>()
headers["Content-Type"] = "application/x-www-form-urlencoded" // URLエンコード形式
return headers
}
override fun getBody(): ByteArray {
return postBody.toByteArray(Charsets.UTF_8) // ボディにエンコード済みのデータを設定
}
}
// リクエストキューに追加
Volley.newRequestQueue(this).add(request)
}
}
fun checkAndRequestPermissions(
context: Context,
permissions: Array<String>,
launcher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
) {
if (
permissions.all {
ContextCompat.checkSelfPermission(
context,
it
) == PackageManager.PERMISSION_GRANTED
}
) {
// パーミッションが与えられている
Log.d("MainActivity", "Already granted")
} else {
// パーミッションを要求
launcher.launch(permissions)
}
}
@Composable
fun MainScreen(modifier: Modifier = Modifier, tokenState: MutableState<String?>) {
val context = LocalContext.current
val permissions = arrayOf(
Manifest.permission.POST_NOTIFICATIONS
)
val launcherMultiplePermissions = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissionsMap ->
val areGranted = permissionsMap.values.reduce { acc, next -> acc && next }
if (areGranted) {
// パーミッションが与えられている
Log.d("MainActivity", "Already granted")
} else {
// パーミッションを要求
Log.d("MainActivity", "Show dialog")
}
}
Column(modifier = modifier) {
Text("FCM Token: ${tokenState.value ?: "Loading..."}")
Button(
onClick = {
checkAndRequestPermissions(
context,
permissions,
launcherMultiplePermissions
)
}
) {
Text("通知を許可(Android13用)")
}
}
}
@Preview(showBackground = true)
@Composable
fun MainScreenPreview() {
var tokenState: MutableState<String?>? = null
val localTokenState = remember { mutableStateOf<String?>(null) }
tokenState = localTokenState
MainScreen(modifier = Modifier, localTokenState)
}
■アプリ(iOS)
import SwiftUI
struct ContentView: View {
let publisher = NotificationCenter.default.publisher(for: .deviceToken)
@State var deviceToken: String = ""
var body: some View {
VStack {
Text("DeviceToken")
TextField("Device Token is Empty", text: $deviceToken)
.padding()
}
.onReceive(publisher) { message in
if let deviceToken = message.object as? String {
sendDeviceToken(deviceToken)
}
}
}
private func sendDeviceToken(_ token: String) {
guard let url = URL(string: "https://example.com/app/save_device_token.php") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = "device_token=\(token)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)?.data(using: .utf8)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
print("Error: \(error)")
return
}
guard let data = data else {
return
}
do {
let object = try JSONSerialization.jsonObject(with: data, options: [])
print(object)
DispatchQueue.main.async {
self.deviceToken = token
}
} catch {
print("JSON parsing error: \(error)")
}
}
task.resume()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
トラブル
■プッシュ通知が届かない
以下などを確認する。
・アプリのパッケージ名を確認し、それに対応する設定が行われているか。
・Firebase、Apple Developer Program、AmazonSNS でそれぞれ設定内容が正しいか。
Androidの場合は以下も確認する。
・USBデバッグでインストールしたか、APKを書き出してインストールしたか。
iOSの場合は以下も確認する。
・Distribution証明書とDevelopment証明書、適切な方を使用しているか。
・アカウントの有効期限切れ、証明書の有効期限切れになっていないか。
・「Development SSL Certificate」で設定をしたのか「Production SSL Certificate」で設定したのか。
後述の「送信サーバによってはプッシュ通知が届かない」も参照。
■送信サーバによってはプッシュ通知が届かない
開発環境から突然プッシュ通知を送れなくなった。
ただし、別サーバで同じプログラムを実行すると送れる。
Amazon SNS からのエラーメッセージを確認すると、以下のようになっていた。
Error executing "Publish" on "https://sns.ap-northeast-1.amazonaws.com"; AWS HTTP error: Client error: `POST https://sns.ap-northeast-1.amazonaws.com` resulted in a `403 Forbidden` response:
<ErrorResponse xmlns="http://sns.amazonaws.com/doc/2010-03-31/">
<Error>
<Type>Sender</Type>
<Code>SignatureDo (truncated...)
SignatureDoesNotMatch (client): Signature expired: 20200129T081327Z is now earlier than 20200129T081427Z (20200129T082927Z - 15 min.) - <ErrorResponse xmlns="http://sns.amazonaws.com/doc/2010-03-31/">
<Error>
<Type>Sender</Type>
<Code>SignatureDoesNotMatch</Code>
<Message>Signature expired: 20200129T081327Z is now earlier than 20200129T081427Z (20200129T082927Z - 15 min.)</Message>
</Error>
<RequestId>b8fd243d-c596-5897-9820-72968131164b</RequestId>
</ErrorResponse>
「20200129T081327Z is now earlier than 20200129T081427Z」となっている。
サーバの時間がおかしい。
# date
2020年 1月 29日 水曜日 17:18:30 JST
サーバの時間を確認すると、15分程度遅れていた。
# timedatectl set-timezone Asia/Tokyo
# date
2020年 1月 29日 水曜日 17:19:20 JST
タイムゾーンを再設定しても変化なし。
# chronyc -a makestep
200 OK
chronyc で強制的に時間を調整してみる。
(chronyc がインストールされていなければインストールする。)
# date
2020年 1月 29日 水曜日 17:36:18 JST
時間がぴったりになった。
この状態ならAmazonSNSでプッシュ通知が送れるようになった。
Centos7の時間がずれた - Qiita
https://qiita.com/SwuBHj8aKGqBKHet/items/2f6a2003851420b460ba
■トークン管理
プッシュ通知の送信先となるトークンは、ときどき変更されることがあるので注意。
最新の登録情報を維持し続けるために、以下のような手段がある。
Amazon SNS のモバイルトークン管理についてのベストプラクティス | Developers.IO
https://dev.classmethod.jp/cloud/aws/sns-mobile-token/
[PHP]Amazon SNS を使い、iOS・AndroidへPUSH通知 - SNS設定編 - - Qiita
https://qiita.com/kei_ohsaki/items/723595671767fcae1ec1
[PHP]Amazon SNS を使い、iOS・AndroidへPUSH通知 - デバイストークン登録・更新編 - - Qiita
https://qiita.com/kei_ohsaki/items/257e4f42224dd89e15ce
[PHP]Amazon SNS を使い、iOS・AndroidへPUSH通知 - トピック作成・配信編 - - Qiita
https://qiita.com/kei_ohsaki/items/564e75b346e1495a33e7
Amazon SNS モバイルプッシュ API の使用 - Amazon Simple Notification Service
https://docs.aws.amazon.com/ja_jp/sns/latest/dg/mobile-push-api.html
■APIの取得件数制限
「listEndpointsByPlatformApplication」の場合、以下の注意点がある。
Lists the endpoints and endpoint attributes for devices in a supported push notification service, such as GCM and APNS.
The results for ListEndpointsByPlatformApplication are paginated and return a limited list of endpoints, up to 100.
If additional records are available after the first page results, then a NextToken string will be returned.
To receive the next page, you call ListEndpointsByPlatformApplication again using the NextToken string received from the previous call.
When there are no more records to return, NextToken will be null.
For more information, see Using Amazon SNS Mobile Push Notifications.
This action is throttled at 30 transactions per second (TPS).
1回のリクエストで100件まで。
1秒間に30トランザクションまで。
一度に3000件以上取る場合は工夫が必要かも。
トークン管理のために
「無効になったトークンをすべて取得して何らかの処理をする」
とするよりも、上のリンク先で紹介されているように
「アプリ起動時に毎回トークンの有効/無効を確認する。無効ならトークンを差し替える」
とする方がいいかも。
■その他
プッシュ通知はどうしても
「GoogleやAppleが作ったブラックボックスを利用する」
となるので、そういった面でのリスクは常に発生する。
プッシュ通知の未達/遅延問題に真正面から取り組むLINE公式アカウント開発チーム - LINE ENGINEERING
https://engineering.linecorp.com/ja/interview/aim-at-100-push-notification-knowledge-obtained/
> 通知の送信には、AndroidであればGoogle、iOSはAppleの仕組みを利用することになり、その仕組みは我々からするとブラックボックスであり、すべてをつまびらかにすることはできない
> Androidの中に、該当アプリで一定数以上の通知がすでに表示されていると、それ以上の通知を抑止するというロジックが組み込まれていた
> LINE公式アカウントのように、通知量が多くなるアプリではクリティカルな問題になる可能性があるので注意が必要でしょう
考察: アプリのIDについて
※アプリは本番用と開発用でパッケージ名を変える方が無難。
(1端末に両方インストールできるようにする。)
※アプリのIDは、アプリ名を初期として自動作成される。例えば「PushTest1-Dev」というアプリ名で普通に作ると
Android ... net.refirio.pushtest1dev
iOS ... net.refirio.PushTest1-Dev
のようになる。
いったん手動で「pushtest1」というアプリ名を付け、
net.refirio.pushtest1
のパッケージ名で作成してから
net.refirio.pushtest1
net.refirio.pushtest1.stg
net.refirio.pushtest1.dev
に切り替えられるように同プロジェクト内で最初に設定しておくといい。
(本番と開発でソースコードが別々になるのは、管理が大変すぎるので避ける。)
※切り替える仕組みはXcodeならSchemeを、AndroidStudioならFlavorを使うといい。
可能ならiOSとAndroidの両方で統一したパッケージ名を使えるように調整するといい。
が、例えば net.refirio.android.pushtest1 と net.refirio.ios.pushtest1 などのように付けるのも一つの手段。
詳細は AndroidStudio.txt と Xcode.txt の「製品用、開発用などの切り分け」を参照。
以下、専用の仕組みがないか調べたときのメモ。
iPhoneアプリ開発でデバッグ版とリリース版をきれいに同居させる - しめ鯖日記
http://www.cl9.info/entry/2015/07/29/010020
iOS開発で環境ごとにアイコンやアプリ名、コード等を切り分けるオレオレプラクティス - Qiita
https://qiita.com/KazaKago/items/2835d76ced43f913c31d
以下は Bundle ID を変更しているが、記事が古いのでSchemeでの変更ができなかったときの話かも。
iPhoneアプリ開発でBundle IDを書き分けてビルドする方法 | SONICMOOV LAB
https://lab.sonicmoov.com/development/iphone-app-dev/build-change-bundle-id/
Android Studio でも変更できなくは無いみたいだが、
「本番用と開発版を切り替える」というより「パッケージ名を別のものに変更したい」という場合の対応みたい。
【AndroidStudio】パッケージ名を変更する方法 - Qiita
https://qiita.com/n-yusa/items/413c8a131ebc451e80f8
※「PushTest1」などの名前はすべて「PushTest1-Dev」のようにしておく方がいいかも。
プッシュ通知の証明書なども開発用と本番用が必要のため。
考察: 処理の流れについて
※ログインして使うアプリの設計メモ。
※ユーザ情報のテーブルとデバイス情報のテーブルがある想定。
※「ログインしなくても基本機能は使える」「プッシュ通知の送信を許可しなかった」も考慮する。
※デバイストークンは非同期で取得されるので、
「アプリ起動時に端末情報を同期」と「アプリ起動時にデバイストークンを同期」の計2つのAPIが必要になる。
■前提
起動時にアプリから実行するAPIは以下の2つ。
ただしプッシュ通知が許可されない(デバイストークンを取得できない)場合は前者のみ実行する。
アプリ起動時に端末情報を同期。(OSバージョンやアプリバージョンなど、デバイストークン以外の情報を同期。)
/api/device/sync
アプリ起動時にデバイストークンを同期。
/api/device/sync_token
■アプリ初回起動時
「アプリ起動時に端末情報を同期」を使用し、アプリからPHPに、OSバージョンやアプリバージョンなどを送る。
PHPがDBに、OSバージョンやアプリバージョンなどを記録する。さらに全端末でユニークな識別コードを作成し、合わせてDBに保存する。
PHPからアプリに、識別コードを返す。アプリはこの値を保存しておく。
プッシュ通知が許可されるとデバイストークンを取得できるようになる。
「アプリ起動時にデバイストークンを同期」を使用し、アプリからPHPに、識別コードとともにデバイストークンを送る。
PHPがAmazonSNSから、エンドポイントを取得する。
PHPがDBに、デバイストークンとエンドポイントを記録する。
PHPからアプリに、識別コードを返す(正常終了の判定などに使う。)
■アプリ次回起動時
「アプリ起動時に端末情報を同期」を使用し、アプリからPHPに、識別コードとともにOSバージョンやアプリバージョンなどを送る。
PHPがDBに、OSバージョンやアプリバージョンなどを記録する。(最新情報として上書き更新する。)
プッシュ通知が許可されていればデバイストークンを取得できる。
「アプリ起動時にデバイストークンを同期」を使用し、アプリからPHPに、識別コードとともにデバイストークンを送る。
PHPがAmazonSNSから、エンドポイントを取得する。
PHPがDBに、デバイストークンとエンドポイントを記録する。(対象データは、識別コードをもとに判断する。)
PHPからアプリに、識別コードを返す。(正常終了の判定などに使う。)
■プッシュ通知拒否時
アプリ初回起動時にプッシュ通知の送信を拒否した場合、デバイストークンの取得ができない。
この場合、「プッシュ通知が許可されていれば」の処理は行われないので、デバイストークンやエンドポイントは無いままで動作する。
■端末ごとの設定
端末ごとの設定を持ちたければ devices のレコードに保存する。
必要に応じて、設定は端末内にもキャッシュとして保存しておく。(インターネットに繋がっていなくても設定内容を参照できるように。)
■ログイン時
アプリ内でユーザ名とパスワードを入力し、その値をPHPに送る。
認証情報が正しければ、データベーステーブルのデバイス情報をユーザ情報に紐付ける。
デバイス情報にはログイン中か否かのステータスも持たせておき、そのステータスをログイン中にする。
(厳密なログイン判定が不要なら、デバイス側に単純なログインフラグを持たせておくか。厳密なログイン判定が必要なら、ユーザ情報にも識別コードを持たせてそれをアプリ内に記録させ、アプリ起動時に毎回サーバ側でチェックするか。)
以降のログインは、アプリ内に保存されている識別コードをもとに行う。
■別端末でログイン時
上とまったく同じ流れでデバイストークンやエンドポイントを記録する。
端末情報テーブルには、同じユーザIDのデータが別途作成される。
■ログアウト時
アプリからPHPに、デバイストークンを投げてくる。
データベーステーブルのデバイス情報とユーザ情報の紐付けを解除する。
ログイン中か否かのステータスをログアウトにする。
■ログアウトして別ユーザでログイン時
「ログアウト時」の手順でログアウトし、「ログイン時」の手順でログインする。
■プッシュ通知送信時
PHPがAmazonSNSを使って、エンドポイントに対してプッシュ通知を送信する。
1ユーザが複数の端末を持っていれば、それぞれにプッシュ通知が送信される。
■セッションタイムアウト時
デバイストークンはDBにあるので、それをもとにプッシュ通知は届き続ける。
アプリを立ち上げると、その時点でデバイストークンの更新処理が走る。
※iOSアップデートのタイミングでデバイストークンが変わる可能性があるらしい。
デバイストークンが変わるタイミングは不定らしいので、デバイストークンがいつ変わっても大丈夫な仕組みにする必要がある。
iOS 9からAPNsデバイストークンがアプリインストールの度に変わるようになったようです - Qiita
https://qiita.com/mono0926/items/9ef83c8b0de0e84118ac
考察: テーブル設計
CREATE TABLE IF NOT EXISTS users(
id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '代理キー',
created DATETIME NOT NULL COMMENT '作成日時',
modified DATETIME NOT NULL COMMENT '更新日時',
deleted DATETIME COMMENT '削除日時',
username VARCHAR(80) NOT NULL UNIQUE COMMENT 'ユーザ名',
password VARCHAR(80) COMMENT 'パスワード',
password_salt VARCHAR(80) COMMENT 'パスワードのソルト',
email VARCHAR(255) NOT NULL UNIQUE COMMENT 'メールアドレス',
loggedin DATETIME COMMENT '最終ログイン日時',
failed INT UNSIGNED COMMENT 'ログイン失敗回数',
failed_last DATETIME COMMENT '最終ログイン失敗日時',
option1 VARCHAR(80) NOT NULL COMMENT 'ユーザごとの設定例1',
option2 VARCHAR(80) NOT NULL COMMENT 'ユーザごとの設定例2',
option3 VARCHAR(80) NOT NULL COMMENT 'ユーザごとの設定例3',
PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT 'ユーザ';
CREATE TABLE IF NOT EXISTS devices(
id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '代理キー',
created DATETIME NOT NULL COMMENT '作成日時',
modified DATETIME NOT NULL COMMENT '更新日時',
deleted DATETIME COMMENT '削除日時',
code INT UNSIGNED NOT NULL UNIQUE COMMENT 'コード(識別コード。代理キーを非可逆暗号化すれば、推測も重複も避けられそう)',
useragent VARCHAR(255) NOT NULL COMMENT 'ユーザエージェント(あまり意味がないかもしれないが、一応記録しておくか)',
platform VARCHAR(20) NOT NULL COMMENT 'プラットフォーム(例: ios / android)',
os_version VARCHAR(80) NOT NULL COMMENT 'OSバージョン(例: 13.2.3)',
app_version VARCHAR(80) NOT NULL COMMENT 'アプリバージョン(例: 1.0.2)',
ip VARCHAR(80) NOT NULL COMMENT '最終IPアドレス',
token VARCHAR(255) UNIQUE COMMENT 'デバイストークン',
endpoint VARCHAR(255) COMMENT 'エンドポイント',
disabled DATETIME COMMENT '無効日時',
notification TINYINT(1) UNSIGNED NOT NULL COMMENT '通知', # この設定は不要か。OSレベルで通知がOFFでもとりあえず送信&受け取りは行われる?アプリ内で設定のON/OFFを設ける場合には必要そう
option1 VARCHAR(80) NOT NULL COMMENT 'デバイスごとの設定例1',
option2 VARCHAR(80) NOT NULL COMMENT 'デバイスごとの設定例2',
option3 VARCHAR(80) NOT NULL COMMENT 'デバイスごとの設定例3',
user_id INT UNSIGNED NOT NULL COMMENT '外部キー ユーザ',
loggedin DATETIME COMMENT '最終ログイン日時',
loggedout DATETIME COMMENT '最終ログアウト日時',
PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT 'デバイス';
■以下は要検討
アプリ起動時にインターネットに繋がっていなければどうするか。
アプリの起動自体ができない…などは避けたい。
サーバ上の設定を取得する必要があるなら、その内容はアプリ内に保持しておいて起動時に毎回更新するか。
アプリ内の設定でプッシュ通知の有効/無効を切り替えたとき、実装内容によっては一斉配信用のトピックからも追加/除外する必要がある。
何かしらの通信エラーでデータベース内の値とトピックの値が異なると問題なので、データベースのロールバックなどを活用する。
「データベースから削除 → トピックから削除 → 正常終了ならコミット/そうでなければロールバック」
で対応できるか。
アプリデータを初期化して起動した場合など、同じデバイストークンがすでにデータベースに登録されている可能性はある。
プッシュ通知が重複して何度も届かないように、初回起動の新規登録時は「すでに同じデバイストークンのデータがあれば、そのデバイストークンはNULLにする。user_idもNULLにする」としておく。
仮に「すでに同じデバイストークンのデータがあれば、そのデータを自身のデータとして紐付ける」をすると、「アプリを初期化したのに何故かデータが復元される」となるので避ける。
ログインした状態でアプリの設定をリセットしたら、サーバサイドでは「ログイン中」の情報が残ってしまうが、。
次回初回起動の新規登録時、古いデータのデバイストークンとuser_idにはNULLが登録されるので大丈夫のはず。
お知らせの未読数をアプリのアイコンに表示する場合、既読数もしくは未読数をサーバサイドで管理する必要がある。
(プッシュ通知を送信する際に、「アイコンの数字には何を表示するか」を指定する必要があるため。)
が、それだとトピックへの一斉配信が使えない。(同一の内容を送信するため、ユーザごとの既読数などは送れない。)
プッシュ通知を受け取ってからアプリ側で数字をインクリメントさせる必要があるが、通常のプッシュ通知だとバックグラウンド時に受信したときの処理を指定できない。
サイレントプッシュを使えば一応は可能だが、バッジのためだけに常にサイレントプッシュを使うのもイマイチか。
「何かしらのお知らせがあれば1と表示する」のように簡易な実装にする方が無難か。
iOSのサイレントプッシュを試してみる - しめ鯖日記
http://www.cl9.info/entry/2017/10/14/145342
Swiftでサイレントプッシュを送る - ニフクラ mobile backend(mBaaS)お役立ちブログ
https://blog.mbaas.nifcloud.com/entry/2018/11/06/144238
iOS 13以上でサイレントプッシュのdidReceiveRemoteNotificationが呼ばれない問題 - Qiita
https://qiita.com/knagauchi/items/64dd9d57f123a24a4e14
考察: アプリ用API
※ログインの不要な実案件用に設計したもの。
ログインも考慮してAPIを追加調整しておきたい。
■アプリ起動時に端末情報を同期
url: /api/device/sync
method: post
parameters:
_type ... 「json」で固定
code ... 識別コード or 空欄
platform ... プラットフォーム(「ios」もしくは「android」)
os_version ... OSバージョン
app_version ... アプリバージョン
response
{
"status": "OK",
"code": "識別コード",
"notification": "通知", ... 「0」もしくは「1」 # OSレベルで通知がOFFでもとりあえず送信&受け取りは行われるみたい。いったん常に1を返すようにした
"option1": "デバイスごとの設定例1", ... デバイスごとの設定値
"option2": "デバイスごとの設定例2", ... デバイスごとの設定値
"option3": "デバイスごとの設定例3" ... デバイスごとの設定値
}
※返した各値は、アプリ側で値を保持しておく。
※初回はプッシュ通知の使用許可を得る前なので、notification は常に 0 が返される。
■アプリ起動時にデバイストークンを同期
url: /api/device/sync_token
method: post
parameters:
_type ... 「json」で固定
code ... 識別コード
platform ... プラットフォーム(「ios」もしくは「android」)
token ... デバイストークン
response
{
"status": "OK",
"code": "識別コード",
"notification": "通知" ... 「0」もしくは「1」 # OSレベルで通知がOFFでもとりあえず送信&受け取りは行われるみたい。いったん常に1を返すようにした。
}
※返した notification は、アプリ側で値を保持しておく。
※初回起動直後にこのAPIを叩いた場合、プッシュ通知の使用許可を得た直後なので notification には 1 が記録される。つまり必ず 1 が返される。
初回以降は notification は変更せずに現状の値を返す。
(デバイストークンは取れるが、アプリの設定でプッシュ通知は受け取らないようにした…という状態に対応できるように。)
■端末情報を取得
url: /api/device/get
method: get
parameters:
_type ... 「json」で固定
code ... 識別コード or 空欄
response
{
"status": "OK",
"code": "ZZZZZZZZZZ",
"notification": "通知", # 設定画面から呼ばれることは無いので削除するか
"option1": "デバイスごとの設定例1",
"option2": "デバイスごとの設定例2",
"option3": "デバイスごとの設定例3"
}
■端末情報を更新
url: /api/device/post
method: post
parameters:
_type ... 「json」で固定
code ... 識別コード
notification ... 通知(更新したい場合) # 設定画面から呼ばれることは無いので削除するか
option1 ... デバイスごとの設定例1
option2 ... デバイスごとの設定例2
option3 ... デバイスごとの設定例3
response
{
"status": "OK",
}
■テスト用
url: /api/test
method: get
response
{
"status": "OK",
"datetime": "(現在日時)"
}
考察: アプリ用プッシュ通知
以下のデータ構造でプッシュ通知を送信する。
$message = 'メッセージ';
$target_arn = '送信先'; // エンドポイント
$title = 'タイトル'; // 現状常に null が入る
$url = 'URL'; // 「/webview/news」などアプリで開くべきURLが入る。null が入る可能性がある
// アプリから「/webview/redirect?url=/webview/news」のように開くと、
// プッシュ通知タップ回数を加算したうえでURLのページへリダイレクトする(要セッション)
$gcm = json_encode([
'data' => [
'title' => $title,
'body' => $message,
'url' => $url
],
]);
$apns = json_encode([
'aps' => [
'alert' => [
'title' => $title,
'body' => $message,
],
'badge' => 1,
'sound' => 'default'
],
'url' => $url
]);
$message = [
'default' => $message,
'GCM' => $gcm,
'APNS' => $apns,
'APNS_SANDBOX' => $apns,
];
$parameter = [
'Message' => json_encode($message),
'MessageStructure' => 'json',
'TargetArn' => $target_arn,
];
以降の処理で $parameter の内容を送信。
考察: 本番公開用に作成する(上に整理する前の考察メモ)
■Firebaseから本番用と開発用のそれぞれにプッシュ通知を送信
前提。
例えば先に本番用に作成し、その後単純にAndroidStudioで .dev にして実行すると。
「No matching client found for package name」と表示されてインストールできない。
Firebaseをandroidアプリに追加したときにつまずいたポイント - noyのブログ
http://noy.hatenablog.jp/entry/2018/02/15/121431
google-services.json の「package_name」に「.dev」を付けると .dev でインストールできる。
が、google-services.jsonは本番用なのでプッシュ通知は届かないみたい。
もちろん「.dev」を削除して 「.dev」 なしでインストールするとプッシュ通知は届く。
Firebaseプロジェクトを開発版用に用意する必要がある。
以下、本番用と開発用に分ける手順。(Zabbixからの通知を受け取るためのアプリを作成したときのもの。)
まずは本番&開発などを一緒に管理するためのプロジェクトを作成しておく。
通常の手順で、Firebase本番想定のアプリを作成しておく。
Android パッケージ名: net.refirio.zabbix
アプリのニックネーム: Zabbix
Firebaseで開発用に、プロジェクトではなくアプリを追加する。
Android パッケージ名: net.refirio.zabbix.dev
アプリのニックネーム: Zabbix Dev
ダウンロードした google-services.json の内容を確認すると、本番用と開発用の記述両方があった。
Firebase SDK の追加作業は済んでいるので飛ばす。
その状態で developDebug 版に切り替えてみるとエラーは出なかった。
アプリを実行してインストールを確認すると「正常に追加されました」が表示された。
Firebaseのコンソールからサーバキーを確認すると本番と開発で共通になっていたので、この値を変更する必要はない。
アプリ内でのPHPの通信先調整(本番と開発)は必要。
これで本番と開発版を端末に共存させたうえで、プッシュ通知もそれぞれに送信できる。
1つのソースコードでPHPへの送信先を分ける必要があるので、例えば以下のようにして送信先を振り分ける。
実際はこの手の記述は、設定ファイルなどにまとめて記述すると良さそう。
var target = ""
if ("production".equals(BuildConfig.FLAVOR)) {
target = "https://example.com/tool/zabbix/prod/api.php"
} else {
target = "https://example.com/tool/zabbix/dev/api.php"
}
Log.d("TARGET", target)
同じ手順で
net.refirio.zabbix
net.refirio.zabbix.dev
net.refirio.zabbix.dev.debug
などを1端末に共存させて、それぞれでプッシュ通知を受け取ることも可能なはず。
以下の方法は採用していないが、参考までにメモ。
Firebaseプロジェクトを開発環境用と本番環境用にシンプルに分ける方法 - Qiita
https://qiita.com/hinom77/items/9cd6818210a52f86a6a3
Flavor毎に異なったgoogle-services.jsonをつかう - Qiita
https://qiita.com/gyamoto/items/39351917ee6755abf7bf
■Apple Developer Program から本番用と開発用のそれぞれにプッシュ通知を送信
BundleIDを以下のように設定してみた
Develop_Debug | net.refirio.buildtest1.dev
Develop_Release | net.refirio.buildtest1.dev
Production_Release | net.refirio.buildtest1
アプリケーション名を以下のように設定してみた
Develop_Debug | buildtest1 Dev
Develop_Release | buildtest1 Dev
Production_Release | buildtest1
開発時は net.refirio.zabbix.dev が実機にインストールされるので、この「Development SSL Certificate」に対して登録してみる。
証明書はいったん、本番用に作ったものを共用としてみる。
AmazonSNSで本番用の実機書き出し用として
Zabbix-APNS-Dev
を作ったが
Zabbix-APNS-Development
とした方が良かったかも。
その上で、開発用の実機書き出し用として
Zabbix-APNS-Dev-Development
を作るか。
それなら、Android版も同じルールにしておく方が無難か。
でもAndroidでは「Development SSL Certificate」と「Production SSL Certificate」のような区別は無いので、合わせると冗長か。
と思ったけど、AmazonSNSでは「Apple iOS Prod」と「Apple iOS Dev」が異なれば同じ名前を付けられる。
それなら
本番 ... Zabbix-APNS | Apple iOS Prod
本番 ... Zabbix-APNS_SANDBOX | Apple iOS Dev
開発 ... Zabbix-APNS-Dev | Apple iOS Prod
開発 ... Zabbix-APNS_SANDBOX-Dev | Apple iOS Dev
本番トピック ... Zabbix-APNS-All | Apple iOS Prod
本番トピック ... Zabbix-APNS_SANDBOX-All | Apple iOS Dev
開発トピック ... Zabbix-APNS-Dev-All | Apple iOS Prod
開発トピック ... Zabbix-APNS_SANDBOX-Dev-All | Apple iOS Dev
とする方がいいか。
でもトピックではこのような区別がないので名前で区別するしかないか。
でもPHPプログラムのURLをどうするか。
また「Zabbix-APNS | Apple iOS Dev」などは使うことが無いか。
メモ
■プッシュ通知設定時の挙動
プッシュ通知設定画面へ遷移させる為の実装 - Qiita
https://qiita.com/yamataku29/items/5361d7c3146604dcca44
■プッシュ通知受診時の挙動
Androidでプッシュ受信時にダイアログを表示できるか。
■メインスレッドで処理を実行
[Swift] MainThreadで処理を実行する - Qiita
https://qiita.com/valmet/items/6de0921ca6106414228c
■プッシュ通知の受診時にダイアログを表示する(Android)
Firebaseで送信する際に「Android通知ちゃんねる」「優先度」「通知音」などがある。
これらを変更して対応するのかも。
と思ったが、もともと「優先度」は「高」なので特に変化は無い。
■プッシュ通知のタップ時に任意の画面を開く
プッシュ通知からアプリ内の特定のviewを開く(iOS) - Growthbeat FAQ
https://faq.growthbeat.com/article/88-view-ios
[iOS 10] 画面上部または通知センター上に表示された通知がタップされたときの処理を実装する | DevelopersIO
https://dev.classmethod.jp/smartphone/iphone/wwdc-2016-user-notifications-7/
■アプリからプッシュ通知の設定画面を開く
プッシュ通知設定画面へ遷移させる為の実装 - Qiita
https://qiita.com/yamataku29/items/5361d7c3146604dcca44
■サイレントプッシュ
iOSのPUSH通知(APNS)の特徴・ノウハウまとめ(iOS 9まで対応) - Qiita
https://qiita.com/mono0926/items/df03c61adc56934e2e7a
AWS SNSを使ってiOSへpush通知 - Qiita
https://qiita.com/ijun/items/2cbff7664e49fb93bf39
Xcode:
Background Modes の Modes で「Remote notifications」に加えて「Background fetch」にもチェックを入れる。
PHP:
iOSに送るプッシュ通知データに「'content-available' => 1」を追加する。
$apns = json_encode([
'aps' => [
'alert' => [
'title' => 'タイトル',
'body' => $message,
],
'badge' => 0,
'sound' => 'default',
'content-available' => 1
],
'param1' => 'xxx',
'param2' => 'yyy'
]);
これでプッシュ通知受信時にiOSアプリ側の
// アプリ起動中に通知を受信する処理
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
}
のメソッドが呼ばれるようになるが、これだけだとプッシュ通知受診時にXcodeのコンソールに
「but the completion handler was never called.」
と表示される。以下のページにあるように
iOS アプリでメッセージを受信する | Firebase
https://firebase.google.com/docs/cloud-messaging/ios/receive?hl=ja
上記 application メソッドの最後に以下の処理を追加すると表示されなくなった。
completionHandler(UIBackgroundFetchResult.newData)
これで「プッシュ通知を受信した際に、バックグラウンドで任意の処理を実行する」ができそう。
このうえで、iOSに送る際に
$apns = json_encode([
'aps' => [
/*
'alert' => [
'title' => 'タイトル',
'body' => $message,
],
*/
'badge' => 1,
'sound' => 'default',
'content-available' => 1
],
'param1' => 'xxx',
'param2' => 'yyy'
]);
このように alert のブロックをを丸ごとコメントアウトすると、プッシュ通知は表示されないがプッシュ通知の受信処理は行われる。
さらに「'sound' => 'default',」もコメントアウトすると、プッシュ通知受診時のサウンドやバイブレーションも再生されない。
この仕組を使えば「サーバからの指示によって、裏側でこっそり任意の処理を行わせる」ができそう。
ただし当然ながら、プッシュ通知が許可されていなかったりオフラインになっていたりすると実行できないので注意。
■その他参考になりそうなページ
Amazon SNS で、iOS・Androidにpush通知する方法 - Qiita
https://qiita.com/papettoTV/items/f45f75ce00157f87e41a
phpでAWSのSNSを使ってpush通知を送るときのパターン的なお話 ~ 適当な感じでプログラミングとか!
http://watanabeyu.blogspot.com/2017/01/phpawssnspush.html
【iOS】Firebase の Notifications でプッシュ通知を送る - Qiita
https://qiita.com/koogawa/items/ca8cce019b4ff7ce2576
大規模ネイティブアプリへのプッシュ通知機能導入にあたって考えたこと - Qiita
https://qiita.com/gomi_ningen/items/ab31aa2b3d46bb6ffa5e
OreoでNotificationを表示させる方法 - Qiita
https://qiita.com/naoi/items/367fc23e55292c50d459
kotlin-AndroidでHTTPで取得したデータを表示する - 動かざることバグの如し
http://thr3a.hatenablog.com/entry/20180326/1521995307
AWS SNSを使ってiOSへpush通知 - Qiita
https://qiita.com/ijun/items/2cbff7664e49fb93bf39
iOSのPUSH通知(APNS)の特徴・ノウハウまとめ(iOS 9まで対応) - Qiita
https://qiita.com/mono0926/items/df03c61adc56934e2e7a
大規模ネイティブアプリへのプッシュ通知機能導入にあたって考えたこと - Qiita
https://qiita.com/gomi_ningen/items/ab31aa2b3d46bb6ffa5e
[AWS][iOS] Amazon SNS で APNs に大量 Publish してみた
http://dev.classmethod.jp/cloud/aws/sns-apns-push/