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

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

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

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

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

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を安全に最新版に保つというものがあります。詳しい説明は[こちら](http://dev.classmethod.jp/cloud/aws/sns-mobile-token/)の方が分かりやすく参考にしてみてください。ここでは実装例を取り上げます。

最後に、作成した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をインストールを記述する。

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

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

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

```
bundle install
```

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

```.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のスクリプトである。

```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/