『入る学科間違えた高専生』の日記

プログラミングのコードを書いたりする予定です。あとは日記等。あといつまで高専生やねん

アプリへの招待リンクをFirebase Dynamic Linksを使って実装した

はじめに

前回、サービスを終了したからメインロジックの解説を書いたときに、最後の方に頑張った機能を羅列したのでそれを記事化しようという流れです。
今回は、 Firebase Dynamic Links (以下 FDL ) を使って招待リンクから招待された人がアプリを初回起動したあとにチュートリアルや会員登録が終わったタイミングで友達追加画面を表示するという要件があったので実装したのでそれの備忘録的な?

前回書いた記事は以下

nnsnodnb.hatenablog.jp

環境

  • iOS 11.x〜
  • Xcode 10.2.1
  • Swift 5.0.1
  • Firebase

ちょっとだけサーバサイドの話も噛んだのでそれの環境は以下

今回の詳細な仕様

  • 招待リンクはQRコードとしてユーザ1人1人に発行される
  • 初回インストールを招待リンクから行った場合、ウォークスルー→会員登録→友達追加画面
  • すでにインストールされていて、アプリ内のカメラ機能から友達追加画面の表示
  • すでにインストールされていて、iOSのデフォルトのカメラアプリのQRコード読み取り機能を使ってアプリをディープリンクで開いた場合、アプリ内に友達追加画面の表示

アプリ内のQRカードについて

こんな感じ

実装について

クライアントサイド

QRコードの生成については、 URL から iOS の標準で実装できる方法です。

medium.com

URL の生成は FDLSDK を使って作成しました。

firebase.google.com

今回、 FDL で設定したドメインpipeproduction.page.link というもので設定しています。

import FirebaseAuth
import FirebaseDynamicLinks

// ログインされていないユーザに対しては呼び出してほしくない
func createDynamicLinksForInvites(completionHandler: @escaping (URL?) -> Void) {
    // アプリ内の Realm にユーザ情報を保存しています
    guard let user = User.currentUser else { fatalError("Please login !!") }

    let deepLink = URL(string: "https://example.com/users/\(user.username)?invitedBy=\(user.uid)")!
    let domainURIPrefix = "https://pipeproduction.page.link"

    let linkBuilder = DynamicLinkComponents(link: deepLink, domainURIPrefix: domainURIPrefix)
    linkBuilder?.iOSParameters = DynamicLinkIOSParameters(bundleID: Bundle.main.bundleIdentifier!)
    linkBuilder?.iOSParameters?.appStoreID = "YOUR_APPS_APP_STORE_ID"
    linkBuilder?.navigationInfoParameters = DynamicLinkNavigationInfoParameters()
    linkBuilder?.navigationInfoParameters?.isForcedRedirectEnabled = true
    linkBuilder?.shorten { (url, _, _) in
        guard let url = url else { 
            completionHandler(nil)
            return
        }
        // 毎回URLを生成するのは、UX的に悪いので保存をしておく
        UserDefaults.standard.set(url, forKey: "dynamic_links_invite_shorten_url")
        UserDefaults.standard.synchronize()
        completionHandler(url)
    }
}

上記で実装したメソッドで https://pipeproduction.page.link/1PZvp4yuLr6379yU9 のような URL が取得できます。
上で示した URL は実際には使っていませんでしたが、デバッグ及び Staging 環境では使用ができるものでした。

取得できた URL の最後に ?d=1 を追加すると FDLデバッグができるので紹介すると以下の様な感じ。

f:id:nanashinodonbee:20190826021016j:plain

iOS 以外は問答無用で、サービス側で用意した Web サイトに飛ばします。
iPad 以外の iOS 11.x 以降の端末でインストールが可能でアプリがまだ入っていない場合は AppStore に遷移します。
あとは、お察しの通り。

AppDelegate には以下のようにちょっと実装した

import UIKit
import FirebaseDynamicLinks

@UIApplicationMain
final class AppDelegate: UIResponder, UIApplicationDelegate {

    private(set) var userInfo: [String: Any]? {
        didSet {
            guard let userInfo = userInfo else { return }
            somethings()
        }
    }

    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
        if let dynamicLink = DynamicLinks.dynamicLinks().dynamicLink(fromCustomSchemeURL: url) {
            self.userInfo = dynamicLink.url?.queryParams
        }
        return true
    }

    func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
        guard let webpageURL = userActivity.webpageURL else { return true }
        let handle = DynamicLinks.dynamicLinks().handleUniversalLink(webpageURL) { [weak self] (dynamicLink, _) in
            if let dynamicLink = dynamicLink {
                self?.userInfo = dynamicLink.url?.queryParams
            }
        }
        return handle
    }
}

URLqueryParams については以下

import Foundation

extension URL {

    var queryParams: [String: String] {
        var params: [String: String] = [:]
        guard let comps = URLComponents(string: absoluteString), let queryItems = comps.queryItems else { return params }
        for queryItem in queryItems {
            params[queryItem.name] = queryItem.value
        }
        return params
    }
}

結構実装してから月日が経っててわりとフローが読めなくなってしまったので省略してしまっているのですが、確か初回ログインし終わったぐらいで友達追加画面を表示してたような気がします。
なので、 AppDelegate に実装した userInfo からデータをいい感じに取得してなんちゃらしてあげればいいかなって思います。(やばい、普通になんか当たり前のことしか書いてないから全然ブログ記事に熱を入れられなかった)

サーバサイド

先程、 iOS 側の実装で deepLink/users/{user.username}?invitedBy={user.uid} にしてた理由なんですが、本サービスではユーザ名はユーザが自由に変更可能なモデルでした。もちろん重複チェックや記号チェックはしてます。
大体ないとは思うんですが、ユーザ名が古い QR コードを拾って招待されて入ってきた人が招待してくれた人と友達になれないなぁっていう理由で invitedBy に Firebase Authentication が自動で排出してくれる uid をひっつけて置きました。
正解はないかと思うんですけど、多分上記パターンの場合は見捨てるのが最善策だったのかなって今書いている今は思いました。
また、今回のような実装をした場合、 {user.username} に当たる部分がなんであれ、 uid がちゃんと一致していれば検索が可能なのでサーバサイドで DB なりに uid のユーザがそのユーザ名を使ったことがあるor現在使用中である。等のフラグを設定しておかないといけないかなと思います。(まぁええかと思って今回実装には含めてなかった。)

別にユーザが URL を特に意識しないだろうという想定であれば、ユーザ名を使用せずにそのまま uid のみを使用した URI 設計にしても良かったと思う。ただターゲット層が変な文字列見て怖がるかもしれないなぁという危惧からこういう設計になった。

今回は、ユーザ名が古い場合 && uid を持ったユーザがいる場合は、新しいユーザ名に変更してリダイレクトする仕組みをサーバ側で実装しています。

from django.contrib.auth import get_user_model
from django.http.response import Http404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views.generic import TemplateView


User = get_user_model()


class UserQrCardTemplateView(TemplateView):

    template_name = 'invite.html'

    def get(self, request, *args, **kwargs):
        invited_user_uid = request.GET.get('invitedBy')
        user = User.objects.filter(username=kwargs['username'], is_active=True).first()
        if not user and invited_user_uid:
            user = get_object_or_404(User, uid=invited_user_uid, is_active=True)
            response = redirect(reverse('web:user_qr_card_template_view', args=[user.username]))
            get_params = request.GET.urlencode()
            response['location'] += '?' + get_params
            return response
        elif not user and not invited_user_uid:  # ユーザ名一致なし and invitedBy なし
            raise Http404()
        ・・・

        context = self.get_context_data(**kwargs)

        return self.render_to_response(context)

1番はじめの User のクエリは try ~ except でもいいかなぁ〜。まぁいろんな実装の仕方があると思う。
実際のコードはちょっと違う。

あと、ここの invite.htmliOS 側の QR カードのような UI を提供した。
そのときに URL から QR コードに変換するときに Google Charts API を使用した。

ここで、今回ちょっとだけミスったポイントがあったので紹介したい。
iOS 側で作った感じの URL を手動で生成してあげる操作です。

link = f'https://pipe.bio/users/{user.username}?invitedBy={user.uid}'
invite_url = f'https://pipeproduction.page.link?link={link}&ibi={bundle_id}&isi={appstore_id}&efr=1'

これをこのまま Google Chart API に流すと、当然ながら pipeproduction.page.link のみが URL の判定として取得され、うまく遷移できません。
ちゃんと URL エンコードしようねっていう話だった。

import urllib.parse
encoded_invite_url = urllib.parse.quote(invite_url)

また、今回使用した Google Charts API のリファレンスには QR Code の項目が発見できない(古い方では非推奨として確認できる)のでもしかしたら将来的に使えなくなるかもしれない。

URL 生成の部分は、 FDL には REST API が提供されているのでそっち側の使用の検討したのですが、 API リクエスト制限が存在して将来的なことと Web の中に埋め込むだけの URL だしいいやということでやめました。
ちなみに、 URL を短くしたら QR コードが簡素化できるのでめっちゃいいと思う。

firebase.google.com

Firebase Invites について

開発時当時、まだ Firebase Invites は非推奨ではなかったので、一応開発時に入れようかなぁって思っていたのでなぜ、使わなかったのかをもう非推奨になった今意味ないんですが軽く紹介できればと思います。

https://firebase.google.com/docs/invites/ios?hl=jafirebase.google.com

まず、 Invites の実装方法を見てほしいのですが、

7 アプリで Google ログインを実装します。招待状を送信するには、自分の Google アカウントでログインする必要があります。

という項目がありました。
「え?なんで」 となり、あっさりこれをやめました。

最後に

友達追加で思い出したんですが、友達申請が届いた場合の実装でちょっと大変だった仕様があったなぁって思い出したので今度それについても書こうかなって思いました。(この1文で『思』って何回書いてるんだ)

前回の記事で技術スタックを軽く紹介したのですが、機会があったらいい感じにもうちょっと自分がわかる限りで紹介できればいいなって思いました。
あとメッセージのやり取りのところの実装とか、 WebRTC + CallKit + PushKit での通話機能とか。

この記事、適当なことしか書いてない気がする。ごめんなさい。いつもか。

本当に最後なんですが、ラブライブ!フェスの1日目が最速先行申込抽選で当選しました!!