Firebase HostingでFlutterアプリのプライバシーポリシーのページを作る


Flutterで作ったiOSのアプリをApp Store Connect上げようとしたら、プライバシーポリシーページが無料アプリでも必須だったので、仕方なく作成することにしました。

というか、Firebase Analytics入れたので、多分Playストアアプリでも必要ではありますね。そして広告IDを収集していることになるので、初回起動時の利用規約ページの表示と同意操作も必要ですね・・・

静的ページ(HTMLファイル)をどこかのサーバーに載せなきゃならないのですが、お金が掛かります。
無料でHPとか置けるような所もありますが、なんかそれだけのためにアカウント作っていろいろやるのは面倒です。(Webサービスと連携していて既にそういう環境があるなら別ですが)

そこでふと、思い出したのが、**Firebase Hosting**です。
タイトルだけ見て気になっていたので、これを機に試してみることにしました。

# 環境など

|ツールなど|バージョンなど|
|:--|:--|
| MacBook Air Early2015|macOS Mojave 10.14.6|
|Android Studio |3.6.1|
|Java |1.8.0_131|
|Flutter|1.12.13+hotfix.9|
|Dart |2.7.2|
|Xcode |11.3|
|Homebrew| 2.2.15|


# 概要

## 1.Firebase Hostingで出来ること
こちらに詳しくあります。
https://www.topgate.co.jp/firebase04-firebase-hosting-deploy-website

静的なページをデプロイするのに向いていると。
そしてそのデプロイ単位でロールバックなどが出来ると。
よさげです。



## 2.課金
気になるのは、Firebaseは従量課金されるので、気付いたらウン百万とかいう話も聞こえます。
なので課金がどうなっているのかも調べました。

こちらが詳しいです。
https://uxbear.me/firebase-hosting/

> 1GBのストレージ、月10GBの転送量まで無料で利用でき、独自ドメインのSSLも無料です。

とのことなので、プライバシーポリシーページや利用規約ページなんかのペラなページが数ページならかなり全然余裕そうです。

気になるのは転送量でしょうか。これは多分ページが表示される度に加算されていくのではないかと思います。

まあ、ペラペラなHTMLページなら、多く見積もっても10KBほどで、月に10GBの転送量を超えると言うことは、キャッシュのきかないアクセスが100万くらいいかないと超えないはずなので、心配することはないでしょう。攻撃に遭ったら分かりませんけど。

# Hostingを始める

## 1.npmのインストール

npmが必要らしいので、まずbrewからインストールします。

```console
$ brew update
$ brew install npm
```

## 2.Hostingを設定する

[Hostingを開始する]のページにあるとおりに進めていきます。

### (1)Firebase CLI のインストール
書いてあるとおりのコマンドをターミナルで実行します。

Firebase JavaScript SDKについては特に必要ないのでチェックはしません。

### (2)プロジェクトの初期化

ターミナルで`firebase login`してOAuth認証したら、任意のフォルダにサイトプロジェクト用のディレクトリを作成し、そこに移動します。

```console
$ mkdir workspace/flutter/myproject/static_webpages
$ cd workspace/flutter/myproject/static_webpages
```

その後、`firebase init`すると、何やらサービス選択がCIで表示されるので、カーソルで[Hosting]に合わせ、スペースキーを押して、リターンキーを押します。

|![firebase_init.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/5b4c3274-e110-e521-38b8-fec4626b4546.png)|
|:--|

- **? Please select an option:**と聞かれるので、**Use an existing project**にカーソルで矢印を合わせ、リターンキー
- 作成済みのプロジェクトが出るので、Flutterアプリで作っていたプロジェクトを選択してリターンキー
- **public directory**の名前はそのまま`public`でいいので何も入力せずにリターンキー
- 静的ページにするかと聞かれるので、`y`を入力してリターンキー
- ✔  Firebase initialization complete!と出たら成功。

フォルダを見てみると、`public/index.html`が出来ています。

## 3.初めてのデプロイ

### (1)サンプルをそのままデプロイ

```console
$ firebase deploy
```

成功が出たら、https://プロジェクト名.web.app にアクセスしてみましょう。

|![firebase_hosting_deploy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/d1c22dd9-20aa-a5cc-9938-6e964f18b34c.png)|
|:--|


### (2)index.htmlを書き変えてデプロイ

```html
<!DOCTYPE html>
<html>
    <head>
        <title>Firebase Hosting</title>
    </head>
    <body>
        <p>Hello, Firebase Hosting!</p>
    </body>
</html>
```

こんな風に書き変えて、`firebase deploy`してみます。
キャッシュがきいている可能性があるのでリロードで確認を。

|![hosting_redeploy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/c499c9c8-7bde-e4ef-b3fa-558b64ae98be.png)|
|:--|

変わりました。

Firebaseのコンソール画面でHostingのページを見ると、履歴が見えていますね。

![deploy_history.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/8a0d3cfc-10c7-b5fb-32b9-f4d3fc819c7f.png)

古い履歴にカーソルで合わせると右に出てくる三点メニューアイコンをクリックすると、こんなメニューが出てきます。

![hosting_menu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/ef2915ed-5f50-c5ef-b42a-2ae4c2c280c5.png)

簡単そうですね。

# Webページを作る

## 1.利用規約・プライバシーポリシーページ

さて、肝心の利用規約とプライバシーポリシーページの作成です。

英語だと、ジェネレーターサイトなんかもあるようです。
https://www.privacypolicytemplate.net/
https://app-privacy-policy-generator.firebaseapp.com/

日本語での雛形はこちらのサイトなどにありました。
https://topcourt-law.com/terms_of_service/privacy-policy-for-app
https://houmu-bu.com/policy-privacy-1530
https://www.webernote.net/webcreate/privacy-policy-template.html

今回、ターゲット国は日本のみなので、日本語だけ作成してみます。
リジェクトされたら英語も頑張る感じで。

で、利用規約とプライバシーポリシーは1つのページで十分だろうと言うことで、まとめることにしました。

一応参考までに、このような感じです。
色々参考にしたサイトの切り貼りなので、文言が統一されていない感もあるのですが、まあ言いたいことは伝わるかなと思いますが、いかがでしょうか？

<details><summary>サンプルプライバシーポリシーページ</summary><div>

```html
<!DOCTYPE html>
<html>
    <head>
        <title>利用規約・プライバシーポリシー</title>
        <meta name="viewport" content="width=device-width,initial-scale=1">
    </head>
    <body>
    <p>2020年5月10日制定・施行</p>
    <p>
        本アプリサービス運営者(以下運営者)は、以下のとおり個人情報保護方針を定め、個人情報保護の重要性の認識と取組みを徹底することにより、個人情報の保護を推進致します。</p>
    <p>本アプリのご利用をもって、本規約に同意頂いたものと見なします。</p>

<h2>個人情報の収集</h2>
<p>運営者は、本アプリのご利用に際して、以下の利用者情報を取得いたします。</p>
<ul>
<li>アプリケーションの利用状況の収集<br />
  本アプリでは、広告配信のためにGoogle AdMob、利用状況解析のためにGoogle Firebase Analyticsを使用する場合がございます。
  広告配信のために広告IDを取得していますが、個人を特定するためなどには使用しておりません。<br />
  <br />
  取得する情報、利用目的、第三者への提供等の詳細につきましては、以下のプライバシーポリシーのリンクよりご確認ください。<br />
    <ul>
    <li><a href="https://policies.google.com/technologies/ads?hl=ja">AdMob（Google Inc.）</a></li>
    <li><a href="https://policies.google.com/privacy?hl=ja%EF%BB%BF" >Firebase Analytics（Google Inc.）</a></li>
    </ul>
</li>
<li>お問い合わせやご意見を頂く際の個人情報の収集
  <ul>
  <li>メールアドレス</li>
  <li>お問い合わせ内容</li>
  </ul>
</li>
</ul>

<h2>個人情報の管理</h2>
<p>運営者は、お客さまの個人情報を正確かつ最新の状態に保ち、個人情報への不正アクセス・紛失・破損・改ざん・漏洩などを防止するため、セキュリティシステムの維持・管理体制の整備等の必要な措置を講じ、安全対策を実施し個人情報の厳重な管理を行ないます。</p>

<h2>個人情報の利用目的</h2>
<p>お客さまからお預かりした個人情報は、以下の目的で利用します。</p>
<ul>
<li>本アプリサービスのご利用状況の収集</li>
<li>広告表示のための広告IDの収集</li>
<li>運営者からのご連絡やご質問に対する回答として、電子メールや資料のご送付に利用する場合</li>
</ul>

<h2>個人情報の第三者への開示・提供の禁止</h2>
<p>運営者は、お客さまよりお預かりした個人情報を適切に管理し、次のいずれかに該当する場合を除き、個人情報を第三者に開示いたしません。</p>
<ul>
<li>お客さまの同意がある場合</li>
<li>法令に基づき開示することが必要である場合</li>
</ul>

<h2>個人情報の安全対策</h2>
<p>運営者は、個人情報の正確性及び安全性確保のために、セキュリティに万全の対策を講じています。</p>

<h2>ご本人の照会</h2>
<p>お客さまがご本人の個人情報の照会・修正・削除などをご希望される場合には、ご本人であることを確認の上、対応させていただきます。</p>

<h2>法令、規範の遵守と見直し</h2>
<p>運営者は、保有する個人情報に関して適用される日本の法令、その他規範を遵守するとともに、本ポリシーの内容を適宜見直し、その改善に努めます。</p>

<h2>お問い合せ</h2>
<p>本アプリの個人情報の取扱に関するお問い合せは下記までご連絡ください。</p>
<p>Mail: xxxx.yyyyy@mail.com</p>
    </body>
</html>

```
</div></details>

CSSも何も無い本当にベタなページですが、こんなので十分でしょう・・・
余裕があったらそのうちもう少しなんとかするかもだけどしないかも^^;

レスポンシブ設定だけは簡単にでもしておかないとおかしな表示になるので、入れておきましょう。

```html
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
```

## 2.デプロイ

`indexl.html`は要らないので削除します。
そして、先ほど作った`privacypolicy.html`を`public`フォルダに置いて、`firebase deploy`コマンドでアップロードします。

# ポリシー同意ダイアログを表示
アプリの最新バージョンのチェックにFirebaseのRemote Configを使っているので、ポリシーのバージョンもRemote Configで同じように管理することにします。

その前に、Andorid/iOSとも、設定が必要なので入れておきます。

- Android 

```AndroidManifest.xml
    <uses-permission android:name="android.permission.INTERNET"/>
    <application>
     ...
```

- iOS

```info.plist
	<key>io.flutter.embedded_views_preview</key>
	<true/>
```

## 1.ポリシーバージョンチェック

`Updater`というウィジェットで、アプリの最新バージョンチェックを行い、アップデートダイアログを出している機能を作っていました。(詳しくは[こちら](https://qiita.com/kasa_le/items/a7bae1b9aef6dfde2504)を参照)、そのチェックの中で一緒にポリシーバージョンのチェックも行って、初回または最後に同意したバージョンより新しければ表示する、というようにしました。

### (1)バージョンチェックサービス

アプリの最新バージョンチェックは`VersionCheckService`というクラスを作って、[get_it](https://pub.dev/packages/get_it)パッケージでDIしています。

```dart
class VersionCheckService{
  Future<bool> versionCheck() async {
    // remote config
    final remoteConfig = await RemoteConfig.instance;

    try {
      // 何度も取得するのを防ぐため、2時間ほどはキャッシュさせる
      await remoteConfig.fetch(expiration: const Duration(hours: 2));
      await remoteConfig.activateFetched();

      // アプリバージョン比較
      _needUpdate = await _checkAppUpdate(remoteConfig);

      return true;
    } on FetchThrottledException catch (exception) {
      // Fetch throttled.
      print(exception);
    } catch (exception) {
      print('Unable to fetch remote config. Cached or default values will be '
          'used');
    }
    return false;
  }
}
```

ここに、ポリシーバージョンチェックも入れました。

```dart
      // ポリシーバージョン比較
      _needPolicyAgreement = await _checkPolicyVersion(remoteConfig);
```

`_checkPolicyVersion`はこんな感じです。

```dart
  /// 利用規約バージョンチェック
  Future<bool> _checkPolicyVersion(RemoteConfig remoteConfig) async {
    final policyConfigName = bool.fromEnvironment('dart.vm.product')
        ? POLICY_VERSION_CONFIG
        : DEV_POLICY_CONFIG;

    // ポリシーバージョンを取得
    _newPolicyVersion = remoteConfig.getInt(policyConfigName);
    SharedPreferences sharedPreference = await SharedPreferences.getInstance();

    int agreeVersion =
        sharedPreference.getInt(PreferenceKey.POLICY_AGREE_VERSION) ?? 0;
    if (_newPolicyVersion > agreeVersion) {
      return true;
    }
    return false;
  }
```

RemoteConfigに、開発版と本番用のポリシーバージョンを作りました。

|![remoteconfig_policy_versions.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/b9669536-a987-3383-fe22-162bca9a87cf.png)|
|:--|

### (2)同意ダイアログ表示

上記のチェック関数を経て、表示が必要であれば、ダイアログを表示します。
規約ページはWebページなので、[flutter_inappwebview](https://pub.dev/packages/flutter_inappwebview)パッケージを使って、ダイアログにWebViewを埋め込んでいます。

```pubspec.yaml
  flutter_inappwebview: ^3.0.0
```

```dart
  /// ポリシー同意ダイアログ表示
  void _showAgreementDialog(BuildContext context) {
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (BuildContext context) {
        return new WillPopScope(
          child: _createPlatformAlertDialogPolicy(context),
          onWillPop: () async => false,
        );
      },
    );
  }

  /// ポリシー同意ダイアログ作成
  Widget _createPlatformAlertDialogPolicy(BuildContext context) {
    final title = "利用規約";
    final btnLabel = "同意する";
    return AlertDialog(
      key: Key('policy dialog'),
      title: Text(title),
      content: InAppWebView(
        initialUrl: POLICY_URL,
        shouldOverrideUrlLoading: (controller, request) =>
            _shouldOverrideUrlLoading(controller, request),
        initialOptions: InAppWebViewWidgetOptions(
          crossPlatform: InAppWebViewOptions(
            javaScriptEnabled: false,
            clearCache: true,
            useShouldOverrideUrlLoading: true,
          ),
        ),
      ),
      actions: <Widget>[
        FlatButton(
          child: Text(
            btnLabel,
            style: TextStyle(color: Colors.red),
          ),
          onPressed: () => _onAgreement(context),
        ),
      ],
    );
  }

  /// 外部URL遷移を拾ってブラウザを起動する
  Future<ShouldOverrideUrlLoadingAction> _shouldOverrideUrlLoading(
      InAppWebViewController controller,
      ShouldOverrideUrlLoadingRequest request) async {
    final requestUrl = request.url;
    _launchUrl(requestUrl);
    return ShouldOverrideUrlLoadingAction.CANCEL;
  }
```

ダイアログ内でリンク先を表示すると戻れないので、ブラウザを起動するようにしています。(横着ですw)

### (3)同意後の処理

同意ダイアログを閉じ、今同意したポリシーバージョンをshared_preferenceに保存します。
その後、アプリのアップデートがあればそれも表示します。ただ、チュートリアルの表示が必要な場合は、そちらから戻ってきてからの表示にします。

```dart
  /// 同意後処理
  void _onAgreement(BuildContext context) async {
    // 同意済みを保存
    final pref = await SharedPreferences.getInstance();
    pref.setInt(PreferenceKey.POLICY_AGREE_VERSION, _checker.newPolicyVersion);
    // Analytics開始
    final analyticsService = locator<AnalyticsService>();
    analyticsService.sendAgreementEvent();

    // ダイアログを閉じる
    _navigationService.goBack();

    // 更新が必要でチュートリアルが終わっている場合直ぐに表示
    if (pref.getBool(PreferenceKey.TUTORIAL_DONE) == true &&
        _checker.needUpdate) {
      _showUpdateDialog(context);
    }
  }
```

なお、チュートリアル画面を閉じたときに、もう一度この`Updater`のBuildが走るため、アップデートダイアログはその時に自動的に表示されるという仕組みです。
チェックが二重に走ってしまいますが、そこはRemoteConfigの`expiration`を少し長めに取ることで回避することにしました。

※[前回の記事](https://qiita.com/kasa_le/items/a7bae1b9aef6dfde2504)では、`Updater`は`StatefulWidget`にしていましたが、今は`StatelessWidget`にして、`WidgetsBinding.instance.addPostFrameCallback`内でチェック処理を走らせています。

ちなみに、iPadだとAlertDialogは幅が異様に狭くて、なかなか残念な感じになりますがとりあえずそのままにします(汗)

==== **追記2020/05/12** ====
AlertDialogの幅を変更する方法を見つけました！
https://stackoverflow.com/a/59130009

`Container`でサイズを指定し、その子にInAppWebViewを入れてやります。
それだけだと四角いダイアログになっちゃうので、`shape`で`RoundedRectangleBorder`を指定してやるという形です。

```dart
    return AlertDialog(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.all(
          Radius.circular(10.0),
        ),
      ),
      key: Key('policy dialog'),
      title: Text(title),
      content: Container(
        width: width * 0.8,
        child: InAppWebView(
```

==== **追記ここまで** ====

# ポリシーページのアプリ内での表示
アプリから常に表示するためのUIがないと、確かAppleさんからリジェクトされます。
なので、メニューを作って表示することにします。
ついでにアプリ情報ページも作って、アプリ名やバージョンを確認しやすくします。

トップページの右上に情報アイコンを追加して、そこをタップすると、アプリのバージョン等を表示するページに遷移するようにして、そのページにポリシーページへのボタンを置きましょうか。

## 1.メニューを追加する

```dart
   return Scaffold(
        appBar: AppBar(
          title: Text(TITLE_TOP),
          actions: <Widget>[
            IconButton(
              key: Key('info icon'),
              icon: const Icon(Icons.info_outline),
              onPressed: () => _showInfoPage(),
            ),
          ],
        ),
```

`_showInfoPage`は以下のような感じです。DIするために`NavigationService`という仕組みにして使っているので戸惑うかも知れませんが、中身は`Navigator.push`しているだけです。

```dart
  /// Infoページへの移動
  void _showInfoPage() {
    // Analytics送信
    final analyticsService = locator<AnalyticsService>();
    analyticsService.sendButtonEvent(buttonName: '情報アイコン');

    final navigationService = locator<NavigationService>();
    navigationService.navigateTo(RoutePathName.INFO);
  }
```

## 2.アプリ情報ページの作成

アイコンとバージョンなんかを表示して、その下にポリシーページに飛ぶボタンをおくくらいでどうでしょうか。
そのうちライセンス表示ボタンなんかも追加しやすいですね。

```dart
/// アプリ情報ページ
/// バージョンとビルド番号を表示
/// アプリ名表示
/// プライバシーポリシーページへのリンク
/// ライセンスも出来れば
class InfoPage extends StatelessWidget {
  final PackageInfoRepository packageInfoRepository;

  InfoPage({Key key, @required this.packageInfoRepository}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('アプリ情報'),
      ),
      body: ChangeNotifierProvider<InfoViewModel>(
        create: (_) =>
            InfoViewModel(packageInfoRepository: packageInfoRepository),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: <Widget>[
            // アプリ情報
            Container(
              width: double.infinity,
              decoration: listDividerDecoration,
              child: Padding(
                padding: EdgeInsets.only(
                  top: 8.0,
                  bottom: 8.0,
                ),
                child: AppVersion(),
              ),
            ),
            // プラポリンク
            Container(
              width: double.infinity,
              decoration: listDividerDecoration,
              child: Center(
                child: InkWell(
                  onTap: () => {_launchUrl()},
                  child: Padding(
                    padding: EdgeInsets.only(
                      top: 20.0,
                      bottom: 20.0,
                    ),
                    child: Text(
                      '規約とプライバシーポリシー',
                      style: listLabelsStyle,
                    ),
                  ),
                ),
              ),
            ),
            // ライセンス?
          ],
        ),
      ),
    );
  }

  /// URLを起動
  void _launchUrl() async {
    // Analytics送信
    final analyticsService = locator<AnalyticsService>();
    analyticsService.sendButtonEvent(buttonName: 'ポリシーページ表示');

    if (await canLaunch(POLICY_URL)) {
      await launch(POLICY_URL);
    } else {
      throw 'Could not launch $POLICY_URL';
    }
  }
}

/// アプリバージョン情報ウィジェット
class AppVersion extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Image.asset(
          'assets/icon/icon.png',
          width: 200,
        ),
        Selector<InfoViewModel, String>(
          selector: (context, model) => model.appName ?? "",
          builder: (context, appName, child) => Text(
            appName,
            style: appNameStyle,
          ),
        ),
        Selector<InfoViewModel, String>(
          selector: (context, model) => model.appVersion,
          builder: (context, version, child) => Text(
            'バージョン $version',
            style: versionStyle,
          ),
        ),
      ],
    );
  }
}
```

こんな見た目になります。

![ios_appinfo_capture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/6aeb2ad9-41b1-c8d8-17a9-63eb8aa7c901.png)

[規約とプライバシーポリシー]をタップすると、ブラウザが起動してポリシーページを表示します。
WebViewにしても良かったんですが面倒でブラウザ起動にしました。

将来的には、問い合わせボタンとかも追加かな？

# 感想
静的ページを作ってFirebase Hostingでデプロイが簡単にできました。
むしろページを表示するためのアプリの実装で週末徹夜する羽目になったとかなんとか・・・

メールアドレスを晒すのが怖いのでどうしようかとも思いますが、今はこのままにします。
問い合わせフォーム作るの大変だし・・・

と思ったけど、Googleフォームで作ればいいんじゃない？と思いついたので、そのうち入れるかもです。

# 参考サイト

レスポンシブ設定について参考にしました。
https://www.asobou.co.jp/blog/web/responsive

FlutterでWebViewをダイアログ内に表示する
https://qiita.com/umechanhika/items/0b69d1eed3220977ff89