Edited at

プッシュ通知をユーザー毎に異なるメッセージで送る by Amazon SNS (Android/iOS 両対応)

プッシュ通知はモバイルアプリにおいて、送信後ユーザーがアプリを開くきっかけともなり非常に重要な機能です。

Amazon SNSではこの機能に対応しており、実装例も数多くネット上で見つかります。全くの初めての場合、Amazon Mobile Hubで最低限実装されたコードを自動生成するサービスがあるのでそちらの方も利用してみると良いでしょう。

一方で、見つかる情報の多くはユーザー全員に同じメッセージを送るものであり、ユーザーの層毎に層の定義を柔軟に生成し異なるメッセージを送ることができれば、よりユーザーに刺さるメッセージを送ることができるようになるでしょう。

しかし、ネット上にあまり実装例が見当たらなかったので記事にさせて頂きます。

Amazon SNSではプッシュ通知を送るために、基本的には以下の処理を行うことで使用することができます。


  1. 初期化処理

  2. Endpointの作成

  3. Topicに対するsubscribeの処理

ここまではネット上で多く情報が見つかります。

以降、SNSで複雑な処理も可能なように、よく使用されると思われるSNSの以下のAPIの実装例を挙げます。


  • ListTopics API

  • CreateTopic API

  • CreatePlatformEndpoint API

  • ListSubscriptions API

  • Subscribe API

  • Unsubscribe API

実装は以下の言語となります。

端末
言語

Android
Java

iOS
Swift3

また、推奨アプローチとして、Endpointが失効してプッシュ通知が送れなくなるのを防ぐためにEndpointを安全に最新版に保つというものがあります。詳しい説明はこちらの方が分かりやすく参考にしてみてください。ここでは実装例を取り上げます。

最後に、作成したTopic毎にプッシュ通知を送るためのスクリプトの実装例(Ruby)も挙げます。


初期化処理

Cognitoで認証後、SNSの各種APIによる処理を行う。


Android

final CognitoCachingCredentialsProvider credentials = credentialsProvider =

new CognitoCachingCredentialsProvider(context,
"AWS_ACCOUNT_ID",
"COGNITO_POOL_ID",
"UNAUTH_ROLE_ARN",
"AUTH_ROLE_ARN",
Regions.AP_NORTHEAST_1
);


iOS

let credentialsProvider:AWSCognitoCredentialsProvider = AWSCognitoCredentialsProvider(regionType: .apNortheast1, identityPoolId: "us-east-1:xxxxxxxxxxxx(Identity pool ID )")

let configuration = AWSServiceConfiguration(region: AWSRegionType.apNortheast1, credentialsProvider: credentialsProvider)
configuration?.timeoutIntervalForResource = 1000
AWSServiceManager.default().defaultServiceConfiguration =
configuration
credentialsProvider.getIdentityId()


ListTopics API

Topicのリストを取得する。


Android

final AmazonSNSClient snsClient = new AmazonSNSClient(credentials);

ListTopicsResult listTopicsResult = snsClient.listTopics();
List<Topic> topics = listTopicsResult.getTopics();


iOS

let sns = AWSSNS.default()

let request: AWSSNSListTopicsInput = AWSSNSListTopicsInput()
sns.listTopics(request).continue(with: AWSExecutor.default(), with: { [weak self] (task: AWSTask) -> AnyObject? in
guard let me = self else { return nil }
if let error = task.error { print("Error: \(error)"); return nil; }

guard let listTopicsResponse = task.result else { return nil }
guard let topics = listTopicsResponse.topics else { return nil }

print(topics)

return nil
})


CreateTopic API

Topicを作成する。


Android

final AmazonSNSClient snsClient = new AmazonSNSClient(credentials);

CreateTopicRequest createTopicRequest = new CreateTopicRequest(topic);
CreateTopicResult createTopicResult = snsClient.createTopic(createTopicRequest);


iOS

let request: AWSSNSCreateTopicInput = AWSSNSCreateTopicInput()

request.name = topic
sns.createTopic(request)


CreatePlatformEndpoint API

Endpointを作成する。


Android

final GoogleCloudMessaging gcm = GoogleCloudMessaging.getInstance(context);

String deviceToken = gcm.register(gcmSenderID);
final AmazonSNSClient snsClient = new AmazonSNSClient(credentials);

CreatePlatformEndpointRequest createRequest = new CreatePlatformEndpointRequest();
createRequest.setToken(deviceToken); createRequest.setPlatformApplicationArn(BuildConfig.AMAZON_SNS_PLATFORM_APPLICATION_ARN);
CreatePlatformEndpointResult platformEndpoint = snsClient.createPlatformEndpoint(createRequest);
String endPoint = platformEndpoint.getEndpointArn();


iOS

let sns = AWSSNS.default()

let input: AWSSNSCreatePlatformEndpointInput = AWSSNSCreatePlatformEndpointInput()
input.token = deviceToken
input.platformApplicationArn = SNS_ARN_DEV
sns.createPlatformEndpoint(input)


ListSubscriptions API

subscribeされているSubscriptionのリストを取得する。ただし、端末毎にsubscribeしているものを取り出すわけではなく、認証に使用したIAMでsubscribeしているもの全体になるので注意が必要。


Android

final AmazonSNSClient snsClient = new AmazonSNSClient(credentials);

ListSubscriptionsResult listSubscriptionsResult = snsClient.listSubscriptions();
List<Subscription> subscriptions = listSubscriptionResult.getSubscriptions();


iOS

let sns = AWSSNS.default()

let req: AWSSNSListSubscriptionsInput = AWSSNSListSubscriptionsInput()
sns.listSubscriptions(req).continue(with: AWSExecutor.default(), with: { (task: AWSTask) -> AnyObject? in
if let error = task.error { print("Error: \(error)"); return nil; }
let result: AWSSNSListSubscriptionsResponse? = task.result
let subscriptions = result?.subscriptions

print(subscriptions)
return nil
})


Subscribe API

存在しているTopic名を指定してsubscribeする。


Android

String title = "トピック名";

String nesessaryTopicArn = SNS_TOPIC_PREFIX + title;
final AmazonSNSClient snsClient = new AmazonSNSClient(credentials);

SubscribeRequest subscribeRequest = new SubscribeRequest(nesessaryTopicArn, "application", endPoint);
SubscribeResult subscribeResult = snsClient.subscribe(subscribeRequest);
String subscriptionArn = subscribeResult.getSubscriptionArn();


iOS

let sns = AWSSNS.default()

let request: AWSSNSSubscribeInput = AWSSNSSubscribeInput()

request.topicArn = SNS_TOPIC_PREFIX + subscribeTopicName
request.protocols = "application"
request.endpoint = endPointArn

sns.subscribe(request).continue(with: AWSExecutor.default(), with: { (task: AWSTask) -> AnyObject? in
if let error = task.error { print("Error: \(error)"); return nil; }
guard let subscribeResponse = task.result else { return nil }
guard let subscriptionArn = subscribeResponse.subscriptionArn else { return nil }

print(subscriptionArn)
return nil
})


Unsubscribe API

subscribeしているSubscription ARNを指定してunsubscribeする。


Android

String subscriptionArn = "unsubscribeするトピックのSubscription ARN"

final AmazonSNSClient snsClient = new AmazonSNSClient(credentials);
UnsubscribeRequest request = new UnsubscribeRequest(subscriptionArn);
snsClient.unsubscribe(request);


iOS

let sns = AWSSNS.default()

let request: AWSSNSUnsubscribeInput = AWSSNSUnsubscribeInput()
request.subscriptionArn = subscriptionArn
sns.unsubscribe(request)


Endpointを安全に最新版に保つための処理

Endpointが有効でなくなる場合を考慮して以下のような処理をすることでより安全にSNSを使用することができる。


Android

Boolean updateNeeded = false, createNeeded = false;

final AmazonSNSClient snsClient = new AmazonSNSClient(credentials);

try {
GetEndpointAttributesRequest getAttributesRequest = new GetEndpointAttributesRequest().withEndpointArn(endPoint);
GetEndpointAttributesResult attributesResult = snsClient.getEndpointAttributes(getAttributesRequest);
Log.d("Endpoint Attributes Result: " + attributesResult.toString());

updateNeeded = !attributesResult.getAttributes().get("Token").equals(gcmToken)
|| !attributesResult.getAttributes().get("Enabled").equalsIgnoreCase("true");
} catch (NotFoundException e) {
createNeeded = true;
Log.d("No endpoint of SNS topic");
}

// Make PlatformEndpoint latest safely
if (createNeeded) {
Logg.d("Invalidate and recreate endpoint");
createEndpoint(snsClient, gcmToken);
}
if (updateNeeded) {
Log.d("Set attributes for endpoint");
setAttribute(snsClient, gcmToken, endPoint);
}

private static void setAttribute(AmazonSNSClient snsClient, String gcmToken, String endPoint) {
Map attributes = new HashMap();
attributes.put("Token", gcmToken);
attributes.put("Enabled", "true");
SetEndpointAttributesRequest setAttributesRequest = new SetEndpointAttributesRequest().withEndpointArn(endPoint).withAttributes(attributes);
snsClient.setEndpointAttributes(setAttributesRequest);
}

private static String createEndpoint(AmazonSNSClient snsClient, String gcmToken) {
String endPoint = "";
try {
CreatePlatformEndpointRequest createRequest = new CreatePlatformEndpointRequest();
createRequest.setToken(gcmToken);
createRequest.setPlatformApplicationArn(BuildConfig.AMAZON_SNS_PLATFORM_APPLICATION_ARN);
CreatePlatformEndpointResult platformEndpoint = snsClient.createPlatformEndpoint(createRequest);
endPoint = platformEndpoint.getEndpointArn();
Log.d("Success to get endpoint: " + endPoint);
return endPoint;
} catch (Exception e) {
Log.d("Failure to get endpoint: " + e);
}
return endPoint;
}


iOS

let sns = AWSSNS.default()

let request: AWSSNSGetEndpointAttributesInput = AWSSNSGetEndpointAttributesInput()
request.endpointArn = arn
sns.getEndpointAttributes(request).continue(with: AWSExecutor.default(), with: { [weak self] (task: AWSTask) in
guard let me = self else { return nil }
if let error = task.error {
print("Error: Endpoint attributes error. The device should create endpoint: \(error)");
me.createEndpoint()
return nil
}

guard let endpointAttributesResult = task.result else { return nil }
let attributes: Dictionary<String, String>? = endpointAttributesResult.attributes
if !(attributes?["token"] == endPointArn) || !(attributes?["Enabled"] == "true") {
print("Error: Device token is old or not valid. The device should set latest information");
me.setAttribute()
}

return nil
})

fileprivate func createEndpoint() {
let sns = AWSSNS.default()
let input: AWSSNSCreatePlatformEndpointInput = AWSSNSCreatePlatformEndpointInput()
input.token = endPointArn
input.platformApplicationArn = SNS_ARN_DEV

sns.createPlatformEndpoint(input)
}

fileprivate func setAttribute() {
let sns = AWSSNS.default()
let request: AWSSNSSetEndpointAttributesInput = AWSSNSSetEndpointAttributesInput()
request.attributes = ["Token": endPointArn, "Enabled": "true"]
sns.setEndpointAttributes(request)
}


RubyによるTopicに応じたプッシュ通知送信処理

以下のようなファイル構成で実装を行う。

Gemfile

.env
push.rb
message/2016/10.ini
11.ini
12.ini

bundlerを使用する。インストールしていない場合は以下のコマンドを実行する。

gem install bundler

対象のレポジトリで以下のようにbundlerの初期化処理を実行する。

bundle init

Gemfileが作成されるので、AWSのSDKとInifileをインストールを記述する。

# frozen_string_literal: true

# A sample Gemfile
source "https://rubygems.org"

# gem "rails"
gem 'aws-sdk', '~> 2'
gem 'inifile'

以下のコマンドを実行して対象ファイルのインストールを行う。

bundle install

.envファイルを以下の通りに作成し環境変数を定義する。

export ACCESS_KEY='AKIA****************'

export SECRET_KEY='*****************************************'

作成後、以下のコマンドを実行して環境変数を読み込む。

source .env

メッセージは年/月.iniで日ごとにTopic毎のメッセージを管理する実装である。以下のように作成する。

例えば、以下の[18]は2016/10/18に向けたメッセージを表す。

[18]

Defualt_Topic = アプリをやろう
Topic1 = Topic1を今すぐチェック!
Topic2 = Topic2は明日間もなく!
Topic3 = Topic3をチェックしておこう!

[19]
Defualt_Topic = アプリ今日はもう確認したかな
Topic1 = Topic1のユーザーはこれ必見
Topic2 = Topic2が今話題
Topic3 = Topic3が熱い

以下、本体となるRubyのスクリプトである。

require 'date'

require 'aws-sdk'
require 'inifile'

FILE_PATH = "./message"
FILE_IDENTIFIER = ".ini"

SNS_PREFIX = "arn:aws:sns:ap-northeast-1:<アカウント番号>:"
DEFAULT_TOPIC = "デフォルトに設定するトピック名"

class Message
def initialize
today = Date.today
year = today.year
month = today.month
filename = FILE_PATH + '/' + year.to_s + '/' + month.to_s + FILE_IDENTIFIER
@init = IniFile.new(:filename => filename, :encoding => 'UTF-8')
end

def show(name)
day = Date.today.day
topic = decide_topic(name)
message = @init[day][topic]

if message.nil? then
@init[day][DEFAULT_TOPIC]
else
message
end
end

def decide_topic(name)
name
end
end

class PushManager
def initialize
Aws.config.update({
region: 'ap-northeast-1',
credentials: Aws::Credentials.new(ENV['ACCESS_KEY'], ENV['SECRET_KEY'])
})

@client = Aws::SNS::Client.new
response = @client.list_topics
@topics = response.topics
end

def publish
@topics.each { |topic|
category = topic.topic_arn[SNS_PREFIX.length..-1]

topic_arn = SNS_PREFIX + category
message = Message.new
p category + ' : ' + message.show(category)
@client.publish(
:topic_arn => topic_arn,
:message => message.show(category)
)
}
end
end

manager = PushManager.new
manager.publish


参考

SNS APIのドキュメント

http://docs.aws.amazon.com/ja_jp/sns/latest/api/Welcome.html

SNS SDKのドキュメント

http://docs.aws.amazon.com/AWSAndroidSDK/latest/javadoc/

SNSのモバイルトークン管理のベストプラクティス

http://dev.classmethod.jp/cloud/aws/sns-mobile-token/

https://aws.amazon.com/jp/blogs/mobile/mobile-token-management-with-amazon-sns/