Swift
Firebase

チャットアプリを開かずに返信する(TodayExtension)

More than 1 year has passed since last update.

チャットアプリでアプリを開かずにササっと返信できる方法はあるのか、考えました。

勉強も兼ねてTodayExtensionで実装してみた。(Notification…という考えが浮かばなかった。。)

TodayExtensionがどういったものかはこちらにまとめました。

元になるチャットアプリには、FirebaseとJSQMessagingViewControllerを使用


Extensionの機能の概要

ezgif.com-resize (2).gif


  • チャットアプリで新着メッセージがある場合、一番新しいメッセージを表示させる。

  • チャットアプリで指定した返信用ワードのボタンで返信する。(6個に固定)

以下のTodayExtensionの制限のため、ボタンに表示された固定のワードを返信することに。


  • TextFieldを使えない 

  • ScrollViewを使えない

  • 高さをなるべく使わないようにする(ガイドラインの指定)

  • FirebaseのiOS SDKを使用できない(UIApplicationの記述があるライブラリは使用不可)

主に実装した機能?仕組み?(エクステンション側)

①AppGroups(アプリとのデータ共有)

チャットアプリ側で決めた返信ワードをエクステンションでも使用できるようにする。

②Firebaseのデータ取得・保存

チャットアプリではFirebaseを使用している。エクステンションでもFirebaseからデータを取得したり、保存する。

iOS用のFirebase SDKが使用できないので、REST APIを使用する。(HTTPリクエストには、Alamofireを使用した)


まとめ(実装してみて)

→ メッセージの返信というアクションは、TodayExtensionでなくて良い(というか、向きでないと思った)。

   

素早く返信できるか、という点で考えて、まずTodayウィジェットを開いて...という動作から始まるので、迅速なアクションはできない...。(結局アプリ開いた方が早い:dizzy_face:

TodayExtensionは、(Appleのドキュメントに書かれていましたが...)


  • 情報の表示(最新)

  • 決まり切ったアクション(例えば、TODOリストのチェックや、SNSのいいね:thumbsup_tone2:など)

といった簡単な機能向けのものだと感じた。

FirebaseのiOSライブラリが使用できないので、代わりにRESTAPIでエクステンションからリクエストを行っている。

FirebaseのRESTAPIでは、データ取得のクエリを使用するには、インデックスの作成を手動で行わなければなりません。無理やりUserIdを含むパスのインデックス作成を手動で行った。(詳細は下)

何か他に良い方法があったのか、とモヤモヤしたまま終了...。

iOS10からNotificationのアクションがカスタマイズできるようなので、次はそちらで実装してみたいです。:joy:


実装で詰まったところ


Firebaseからデータの取得・保存をRestAPIで行う

FirebaseのiOS用ライブラリは、UIApplicationを使用しているためTodayExtensionに導入することができない。

HTTP通信のライブラリAlamofireとFirebaseのRESTAPIを使用して、エクステンションからデータの取得・保存を行った。(Alamofireの導入はPodfileのTodayExtensionのターゲットにAlamofireの記述を追加)


AccessTokenの取得

FirebaseのRESTAPIでデータを取得するには、まず、AccessTokenを取得する必要がある。

AccessTokenの取得には、APIキーが必要なので、Firebaseの管理コンソールから、[アプリの追加]を選択、「ウェブアプリにFirebaseを追加」を選択。

スクリーンショット 2016-12-27 13.42.36.png

APIキーが表示されるので、コピーしておく。

続いて、AccessTokenを取得するコードを記述。


TodayViewController.swift

    // Firebase接続のためのAccessTokenを取得する関数

private func getAccessToken(completionHandler:@escaping (_ token: String, _ error: MessageFetchStatus?) -> ()) {
// APIkey
let apiKey = "APIKey"
// URL
let tokenUrl = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/signupNewUser?key=" + apiKey

// パラメーター
let param = ["returnSecureToken": true]

// POSTリクエスト
Alamofire.request(tokenUrl, method: .post, parameters: param, encoding: JSONEncoding.default).responseJSON { (response) in
if response.result.isSuccess {
if let json = response.result.value as? Dictionary<String, Any>{
if let token = json["idToken"] as? String {
// AccessToken取得成功
self.accessToken = token
completionHandler(token, nil)
} else {
completionHandler("", MessageFetchStatus.tokenEmpty)
}
}
} else {
completionHandler("", MessageFetchStatus.httpError(statusCode: (response.response?.statusCode)!))
}
}
}


POSTリクエストが成功すると、response.result.valueプロパティの"idToken"というキーからAccessTokenが取得できる。

このAccessTokenを使用して、データの取得・保存のリクエストを行う。


Firebaseからデータ取得

データベースから、最新の1件のメッセージを取得する。

RESTAPIでデータの取得をする場合、データが順不同のまま返されるため、クエリで並べ替える必要がある。

Firebaseのクエリ

データベースから最新の1件を取得するために使用したクエリは、以下の2つ。

orderBy: 指定したキーの値でデータをソートしてその順番でデータを返す(今回の場合、"date"キーに保存した日付順で並べ替えた)

orderByは、単独で使用することができません。limitToFirstlimitToLaststartAtendAtequalToのいずれかのフィルターと一緒に使用しなければならない。

limitToLast:並べ替えられたデータの最後から指定した件数を取得する。件数はIntで指定する。

クエリの生成

「"date"(日付)の値で並べ替えて、最新の1件のみを取得する」というクエリを含むURLは以下。

※"date"というキーを指定する時にエンコードエラーにならないように"(ダブルクォーテーション)の代わりに%22を使用!

let limitToLast = 1

let url = "https://pathToMessages/messages.json?orderBy=%22date%22&limitToLast=\(limitToLast)&auth=" + token

クエリの部分だけを抜き出すと、

?orderBy=%22date%22&limitToLast=\(limitToLast)

Firebaseのクエリについてはこちら。(データのフィルタリング)

インデックスの作成

リクエストを行う前に、"date"でデータのソートを行うためにインデックス作成の必要がある。(Firebase RESTAPIでは必須)

※インデックスとはテーブルに含まれるデータの索引のようなもの。

Firebaseに保存したデータの構造は以下のようになっている(ルートの配下)。

"users": {

"userId(unique)": {
"name": "マスオさん",
"messages": {
"messageId": {
"date": "日付",
"senderId": "送信者のID",
"text": "メッセージ"
}
}
}
}

インデックスの作成は、Firebaseのコンソールで、Databaseを選択し、[ルール]タブで行う。

以下のように、パスをネストし、".indexOn"の値として"date"と指定する。

{

"rules": {
".read": "auth != null",
".write": "auth != null",
"users": {
"[userId]" : {
"messages": {
".indexOn": "date"
}
}
}
}
}

"messageId"を記述していませんが、レコードのキーになっているため、"messageId"によるクエリは既に最適化されている。

インデックスの作成についてはこちら(データのインデックス作成

データを取得

インデックスの作成後、URLにGETリクエストを行い、データを取得する。


TodayViewController.swift

    // 最新のメッセージを一件取得する

private func getLatestOneMessage(token: String, completionHandler:@escaping (_ error: MessageFetchStatus?, _ message: String?) -> ()) {
// Query
let limitToLast = 1
// エンコードエラー回避のために %22 を使用している
let url = "https://pathToMessages/messages.json?orderBy=%22date%22&limitToLast=\(limitToLast)&auth=" + token

Alamofire.request(url).responseJSON(completionHandler: { (response) in
if response.result.isSuccess {
//メッセージ取得成功
if let json = response.result.value as? Dictionary<String, Dictionary<String,String>>{
var text = ""
var userid = ""
for (_, value) in json {
text = value["text"]!
userid = value["senderId"]!
}
if let confirmId = self.defaults?.value(forKey: "userid") as? String {
// confirmIdはアプリ側でAppGroupsに保存された自分のID
if confirmId != userid {
// メッセージ取得成功かつ、新メッセージあり
self.userId = confirmId
completionHandler(nil, text)
} else {
// メッセージ取得成功、しかし、新しいメッセージなし
completionHandler(MessageFetchStatus.noMessage, nil)
}
} else {
completionHandler(MessageFetchStatus.userDefaultError, nil)
}
} else {
completionHandler(MessageFetchStatus.serverError, nil)
}
} else {
// データ取得失敗
completionHandler(MessageFetchStatus.httpError(statusCode: (response.response?.statusCode)!), nil)
}
})
}



メッセージの保存

ボタンのテキストをFirebaseのデータベースに保存する。保存したいデータを[String:Any]のディクショナリのパラメーターとして、POSTリクエストする。


TodayViewController.swift

    // メッセージを投稿する

private func addMessage(token: String, text: String) {
let url = "https://pathToMessages/messages.json?auth=" + token

let displayName:String = defaults?.value(forKey: "displayName") as? String ?? "extension-user"
if let id = userId {
let date = getDate()
// 保存するデータ
let param = [
"date": date,
"senderId": id,
"senderName": displayName,
"text": text
] as [String : Any]

Alamofire.request(url, method: .post, parameters: param, encoding: JSONEncoding.default).responseJSON(completionHandler: { (response) in
//成功だったら、「メッセージを送信しました」
if response.result.isSuccess {
self.showMessage(status: MessageFetchStatus.successToSend)
} else {
self.showMessage(status: MessageFetchStatus.failedToSendHttp)
}
})
}
}