中安拓也のブログ

プログラミングについて書くブログ

【Angular(Ionic)】ダークカナリアリリースのモバイルアプリ側対応

はじめに

最近、仕事でダークカナリアリリース対応を少しだけお手伝いしたので、その時のメモになります。

本記事では、モバイルアプリ側の対応だけにしか触れていません。具体的には、ダークカナリアリリースのアプリかどうか判別するときに使用するHTTPヘッダの追加方法についてのみ説明します。

ダークカナリアリリースとは

ダークカナリアリリースとは、本番環境下で新機能を検証することを目的としたリリース手法を指します。

  • カナリアリリース
    • 一部のユーザー・開発者にのみ、新バージョンのアプリケーションをリリースする手法。これにより、新バージョンのアプリで何か問題が発生しても、影響範囲を抑えることができる。
  • ダークカナリアリリース
    • 本番環境で実施するカナリアリリースを指す。対象者は開発者で、新機能を本番環境下で検証することが目的となる。

ダークカナリアリリースのイメージ

今回実装するダークカナリアリリースのイメージ図です。ダークカナリアリリースの実装形式は多種多様だと思うので、下記の図はあくまで一例として捉えてください。

f:id:l08084:20210306175118p:plain

新バージョンのモバイルアプリのリクエストからは、 x-relase-model: dcrというカスタムのHTTPヘッダー(x-relase-model)と値(dcr)を渡すようにします。

HTTPヘッダーx-relase-model: dcrがリクエストに含まれている場合は、新バージョンのAPIにリクエストを割り振ります。逆にHTTPヘッダーx-relase-model: dcrがリクエストに含まれていない場合は、現バージョンのAPIに割り振ることによってダークカナリアリリースを実現します。

HTTPヘッダーの追加方針

ビルド時にカスタムの環境変数RELEASE_MODEL=dcrがセットされている時のみ、x-relase-model: dcrをHTTPヘッダーに追加するようにします。なお、モバイルアプリのリリースはJenkins経由で実施する想定です。

モバイルアプリの環境

今回、ダークカナリア対応を実施するモバイルアプリのフレームワーク・ライブラリのバージョンは下記となります。

ionic infoコマンドの実行結果

$ ionic info

Ionic:

   Ionic CLI                     : 6.11.8 (/usr/local/lib/node_modules/@ionic/cli)
   Ionic Framework               : @ionic/angular 5.6.0
   @angular-devkit/build-angular : 0.1101.4
   @angular-devkit/schematics    : 11.1.4
   @angular/cli                  : 11.1.4
   @ionic/angular-toolkit        : 3.1.0

Utility:

   cordova-res : not installed
   native-run  : not installed

System:

   NodeJS : v12.13.1 (/usr/local/bin/node)
   npm    : 5.6.0
   OS     : macOS Catalina

ビルドスクリプトの作成

該当Ionicプロジェクトのscripts/配下にdcr.jsというスクリプトを作成します。

scripts/dcr.js

'use strict';

/*
 * ダークカナリアリリース用のHTTPヘッダーを追加する。
 */
const env = process.env,
  releaseModel = env.RELEASE_MODEL || '',
  fs = require('fs');

const addReleaseModelHeader = (releaseModel) => {
  if (releaseModel === '') {
    return;
  }
  const httpInterceptorFilePath =
    './src/app/http-interceptors/http-header-interceptor.ts';
  let httpInterceptorFile = fs.readFileSync(httpInterceptorFilePath, 'utf-8');
  httpInterceptorFile = httpInterceptorFile.replace(
    /\/\* \{RELEASE_MODEL\} \*\//,
    `.set('x-relase-model', '${releaseModel}')`
  );
  fs.writeFileSync(httpInterceptorFilePath, httpInterceptorFile);
};

addReleaseModelHeader(releaseModel);

上記のスクリプト(dcr.js)は、環境変数(RELEASE_MODEL)に値が設定されていた場合、TypeScriptファイル(http-header-interceptor.ts)の/* {RELEASE_MODEL} */というコメント文を.set('x-relase-model', [RELEASE_MODELの値])に置き換える処理を行います。

package.jsonに上記のスクリプトを実行するnpm-scriptsを追加します。

package.json

  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e",
    "dcr": "node ./scripts/dcr.js"
  },

追加した行は、"dcr": "node ./scripts/dcr.js"の部分になります。こうすることで、npm run dcrコマンドでdcr.jsを実行することができます。

インターセプターの作成

続いて、HttpClientによるHTTPリクエスト送信とHTTPレスポンス受信の直前に呼び出されるAngularのインターセプターという機能を追加します。

src/app/http-interceptors/http-header-interceptor.ts

import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

@Injectable()
export class HttpHeaderInterceptor implements HttpInterceptor {

  intercept(req: HttpRequest<any>, next: HttpHandler):
    Observable<HttpEvent<any>> {
      const dcrReq = req.clone({
        headers: req.headers /* {RELEASE_MODEL} */
      });
      return next.handle(dcrReq);
  }
}

上記のインターセプターでは、HTTPヘッダーを設定している箇所に/* {RELEASE_MODEL} */というコメントを挿入しているため、コメント/* {RELEASE_MODEL} */dcr.js.set('x-relase-model', [RELEASE_MODELの値])に置き換えることによって、x-relase-model: [RELEASE_MODELの値]をHTTPヘッダーに追加することができます。

動作確認

RELEASE_MODEL='dcr' npm run dcrコマンドを実行すると、http-header-interceptor.tsの内容が下記画像のように変更され、HTTPヘッダーx-relase-model: dcrが追加されることがわかります。

f:id:l08084:20210314184358p:plain
インターセプターの内容がnpm-scriptsによって変更されていることがわかる

実際の運用では上記で作成したソースコードを使って、JenkinsなどのCIでモバイルビルド前に毎回npm run dcrコマンドを実行し、Jenkinsの実行時に環境変数RELEASE_MODEL=dcrがセットされた場合のみ、ダークカナリアリリース用のアプリとしてリリースする、といったことを実施するイメージです。

参考サイト

Angular 日本語ドキュメンテーション

インターセプターによる介入 - Angular After Tutorial

【Cordova】【Android】 Webviewのキャッシュを消す

はじめに

ハイブリッドアプリフレームワークである、Ionic(Cordova)を使用してiOS/Androidのモバイルアプリを作成していたところ、下記のようなセキュリティに関する指摘を受けた。

<指摘内容>
Android端末でアプリを実行すると、端末の下記フォルダに、個人情報が含まれたキャッシュファイルが作成される。
/data/data/<パッケージ名>/cache/Webview/Default/HTTP Cache

上記で受けた指摘の解消、具体的にはキャッシュファイルを削除する方法について、サンプルアプリを例に説明する。

環境

  • cordova-plugin-ionic-webview 4.2.1
  • Android Studio 4.2 Beta 4

ionic infoコマンドの実行結果

$ ionic info

Ionic:

   Ionic CLI                     : 6.11.8 (/usr/local/lib/node_modules/@ionic/cli)
   Ionic Framework               : @ionic/angular 5.5.4
   @angular-devkit/build-angular : 0.1100.7
   @angular-devkit/schematics    : 11.0.7
   @angular/cli                  : 11.0.7
   @ionic/angular-toolkit        : 3.0.0

Cordova:

   Cordova CLI       : 8.0.0
   Cordova Platforms : android 7.0.0
   Cordova Plugins   : cordova-plugin-ionic-keyboard 2.2.0, cordova-plugin-ionic-webview 4.2.1, (and 4 other plugins)

Utility:

   cordova-res : not installed
   native-run  : not installed

System:

   ios-deploy : 1.9.2
   ios-sim    : 6.1.2
   NodeJS     : v12.13.1 (/usr/local/bin/node)
   npm        : 5.6.0
   OS         : macOS Catalina
   Xcode      : Xcode 12.0.1 Build version 12A7300

キャッシュファイルの確認方法

キャッシュファイルの削除方法について説明する前に、Webviewによって作成されるキャッシュファイルの確認方法について説明する。

キャッシュファイルの確認には、Android StudioのDevice File Explorerを使用できる。

  • Device File Explorerの開き方
    • Android Studioを開く
    • メニューの[View] -> [Tool Windows] -> [Device File Explorer]を選択

Android端末を持っていないのでエミュレーターを使用する。

アプリを実行すると、下記のように、/data/data/io.ionic.starter/cache/Webview/Default/HTTP Cache/Code Cache配下にキャッシュファイルが作成されていることがわかる。(io.ionic.starterは実行しているサンプルアプリのパッケージ名)

f:id:l08084:20210214175319p:plain
キャッシュファイルが作成されていることを確認できる

なお、Device File ExplorerはDevice File Explorerを開いた状態のファイルの状態を表示するだけで、最新のファイルの状態を表示してくれるわけではない。

ファイルの最新の状態を確認したいときは、対象のモバイル端末/エミュレーターで同じ端末を選択し直すとファイルの状態が最新化される。

f:id:l08084:20210220153658p:plain
再度同じ端末を選択すると、ファイルの状態が最新化される

そもそもキャッシュを作らないという手もある

これから、/data/data/<パッケージ名>/cache/Webview/Default/HTTP Cache 配下のキャッシュファイルの削除方法について説明していくが、モバイルアプリ側ではなく、API側の修正が可能ならば、そもそもキャッシュファイルを作らないように設定することもできる。

HTTPレスポンスにCache-Control: no-storeHTTPヘッダを登録することで、Webサーバから返されてくるコンテンツをキャッシュに記録することを禁止できるため、/data/data/<パッケージ名>/cache/Webview/Default/HTTP Cache 配下にキャッシュファイルが作成されなくなる。

もし、API側の改修が可能な状況なら、Cache-Control: no-storeHTTPヘッダを登録したほうがいちいちキャッシュファイルを削除するよりもスマートな対応になる可能性が高い。

キャッシュファイルの削除

キャッシュファイルの削除方法について説明していく。

「キャッシュファイルの確認方法」の章で確認したキャッシュファイルを削除する機能をアプリに追加するために、cordova-plugin-ionic-webviewを改修してカスタムプラグインを作成する。

なお、cordova-plugin-ionic-webviewはCordovaアプリでIonic CLIを使用した場合、デフォルトでインストールされるプラグインとなる。

カスタムプラグインの作成

まず、GitHubのcordova-plugin-ionic-webviewリポジトリをForkして別のリポジトリを作成する。

f:id:l08084:20210227145444p:plain
リポジトリをForkする

続いて、Forkして作成したリポジトリをローカルにgit cloneして、cordova-plugin-ionic-webview/src/android配下のIonicWebViewEngine.javaを開いて、下記の二つのメソッド(clearCacheFolderdestroy)を追加する。

import java.io.File;

// ...省略

  /**
   * キャッシュファイルを削除する処理。
   *
   */
  private void clearCacheFolder (File dir) {
    if (dir != null && dir.isDirectory()) {
      try {
        for (File child : dir.listFiles()) {
          if (child.isDirectory()) {
            clearCacheFolder(child);
          }
          child.delete();
        }
      } catch (Exception ex) {
        Log.e(TAG, "Failed to clean the cache, error", ex);
      }
    }
  }

  /**
   * アプリを閉じたときに呼び出されるライフサイクルメソッド。
   * キャッシュファイルの削除処理を呼び出している。
   *
   */
  @Override
  public void destroy() {
    super.destroy();
    File cacheDir = cordova.getActivity().getApplicationContext().getCacheDir();
    clearCacheFolder(cacheDir);
  }

上記のメソッドを追加した後に、変更内容をリポジトリにpushする。

既存のcordova-plugin-ionic-webviewはもう必要ないため、下記のコマンドで削除。

cordova plugin rm cordova-plugin-ionic-webview

代わりにForkしてメソッドを追加したリポジトリを下記のコマンドで追加する。(URLは参考として私のForkしたリポジトリのURLを載せている)

f:id:l08084:20210227151053p:plain
ForkしたリポジトリのURLをコピーして、 インストールを行う

$ cordova plugin add https://github.com/l08084/cordova-plugin-ionic-webview.git

動作確認

上記の改修したプラグインをエミュレーターで実行すると、アプリを閉じたタイミングで/data/data/io.ionic.starter/cache/Webview/Default/HTTP Cache/Code Cache配下のキャッシュファイルが削除されるようになっていることがわかる。

f:id:l08084:20210227170402p:plain
キャッシュファイルが削除されている

苦労した点

  • WebviewのclearCache(true)で上記のキャッシュファイルも削除してくれる認識でいたが違った
  • WebSettingsのsetAppCacheEnabled(false)を使えば、上記のキャッシュファイルを作成しないようになるかと思ったが違った
  • カスタムしたcordova-plugin-ionic-webviewをGitHubに上げずにローカルに配置した状態(custom_plugins/配下に配置した)で使用しようとしたが、Androidビルドするタイミングで下記のエラーが発生して、結局解決できなかった。リポジトリをForkしてGitHub経由でカスタムしたプラグインをインストールするようにしたら下記のエラーは解消した

ローカルにカスタムプラグインを配置した時に発生したエラー

Android Studio project detected
Discovered plugin "cordova-plugin-ionic-webview" in config.xml. Adding it to the project
Failed to restore plugin "cordova-plugin-ionic-webview" from config.xml. You might need to try adding it again. Error: Failed to fetch plugin file:custom_plugins/cordova-plugin-ionic-webview via registry.
Probably this is either a connection problem, or plugin spec is incorrect.
Check your connection and plugin name/version/URL.
Failed to get absolute path to installed module

追記

AndroidでWebviewのキャッシュファイルを削除する用のCordovaプラグインを作成して公開しました。

github.com

Webviewのキャッシュを削除するCordovaプラグインを作成しました - 中安拓也のブログ

参考サイト

重要情報の漏えいにつながるスマホアプリのキャッシュ問題と対策 | セキュリティ対策のラック

WebView  |  Android Developers

WebSettings  |  Android Developers

caching - Android Webview - Completely Clear the Cache - Stack Overflow

How can I read Chrome Cache files? - Stack Overflow

Cordova Web view cache clear in android - Stack Overflow

IPA ISEC セキュア・プログラミング講座:Webアプリケーション編 第5章 暴露対策:プロキシキャッシュ対策

【TypeScript】非同期コールバック関数にasync/awaitを利用する

はじめに

非同期型のコールバック関数(Callback function)にasync/awaitを利用する方法について説明します。使用言語はTypeScriptです。

コールバック関数のままだと困ること

async/awaitを利用しない非同期型のコールバック関数(この例の場合、setTimeout())だと、下記の例のように非同期型のコールバック関数内部の値("成功")を取得して、関数(getStatus())の戻り値として返すことができません。

  /**
   * ステータス(成功/失敗)を返す
   *
   * @private
   * @returns {string} ステータス
   * @memberof Tab1Page
   */
  private getStatus(): string {
    let status = "失敗";
    setTimeout(() => {
      status = "成功";
    }, 1000);
    return status; // 非同期処理のため、"成功"がセットされる前に"失敗"がreturnされてしまう
  }

  /**
   * getStatus()を呼び出して、
   * ステータスを取得する
   *
   * @private
   * @memberof Tab1Page
   */
  private retrieveStatus(): void {
    console.log(this.getStatus()); // "失敗"
  }

コールバック関数をasync/awaitに変換する

下記の例のようにasync/awaitを利用すると非同期型のコールバック関数の中身("成功")を取得して、関数(getStatus())の戻り値として返すことができます。

  /**
   * ステータス(成功/失敗)を返す
   *
   * @private
   * @returns {Promise<string>} ステータス
   * @memberof Tab1Page
   */
  private getStatus(): Promise<string> {
    return new Promise<string>((resolve, _) => {
      let status = "失敗";
      setTimeout(() => {
        status = "成功";
        resolve(status);
      }, 1000);
    });
  }

  /**
   * getStatus()を呼び出して、
   * ステータスを取得する
   *
   * @private
   * @returns {Promise<void>}
   * @memberof Tab1Page
   */
  private async retrieveStatus(): Promise<void> {
    console.log(await this.getStatus()); // "成功"
  }

参考サイト

Callback function (コールバック関数) - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN

【Angular】小文字を大文字に変換するDirective

はじめに

小文字で入力したアルファベットを大文字に変換するDirectiveを作成します。

環境

TypeScriptベースのフレームワークであるAngularを使用しています。

ng versionの実行結果

Angular CLI: 9.1.4
Node: 12.9.1
OS: darwin x64

Angular: 0.0.0
... core, platform-browser, platform-browser-dynamic
Ivy Workspace: Yes

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.901.4
@angular-devkit/build-angular     0.901.4
@angular-devkit/build-optimizer   0.901.4
@angular-devkit/build-webpack     0.901.4
@angular-devkit/core              9.1.4
@angular-devkit/schematics        9.1.4
@angular/animations               9.1.4
@angular/cdk                      9.2.2
@angular/cli                      9.1.4
@angular/common                   9.1.4
@angular/compiler                 9.1.4
@angular/compiler-cli             9.1.4
@angular/fire                     6.0.0
@angular/flex-layout              9.0.0-beta.29
@angular/forms                    9.1.4
@angular/language-service         9.1.4
@angular/router                   9.1.4
@ngtools/webpack                  9.1.4
@schematics/angular               9.1.4
@schematics/update                0.901.4
rxjs                              6.5.5
typescript                        3.7.5
webpack                           4.42.0

大文字に変換するディレクティブ

下記のディレクティブで入力した文字を大文字に変換することができるようになります。

uppercase-change.directive.ts

import { Directive, ElementRef, HostListener } from '@angular/core';

/**
 * アルファベットの小文字を大文字に変換するディレクティブ
 *
 * @export
 * @directive UppercaseChangeDirective
 */
@Directive({
  selector: '[appUppercaseChange]'
})
export class UppercaseChangeDirective {
  private lastValue: string;

  constructor(private ref: ElementRef) {}

  /**
   * 文字を入力したタイミングで呼び出され、大文字に変換する
   *
   * @param {*} event
   * @memberof UppercaseChangeDirective
   */
  @HostListener('input', ['$event'])
  public onInput(event: any): void {
    const start = event.target.selectionStart;
    const end = event.target.selectionEnd;
    event.target.value = event.target.value.toUpperCase();
    event.target.setSelectionRange(start, end);
    event.preventDefault();
    if (
      !this.lastValue ||
      (this.lastValue &&
        event.target.value.length > 0 &&
        this.lastValue !== event.target.value)
    ) {
      this.lastValue = this.ref.nativeElement.value = event.target.value;
      const evt = document.createEvent('HTMLEvents');
      evt.initEvent('input', false, true);
      event.target.dispatchEvent(evt);
    }
  }
}

大文字への変換は下記のコードで実施しており、そのほかのコードは、文字列の途中に文字を挿入したときに、ディレクティブで文字列を変換すると、カーソル(キャレット)が文字列の途中ではなく文末に移動してしまう問題に対応するために書いています。

event.target.value = event.target.value.toUpperCase();

本記事ディレクティブのデモ画面をStackBlitzで公開しているので、実際に触ることができます。

https://ng-uppercase-directive.stackblitz.io

f:id:l08084:20201211200835p:plain
StackBlitzによる本ディレクティブのデモ画面

参考サイト

angular - Directive to upper case input fields - Stack Overflow

【Ionic(Cordova)】GradleによるAndroidビルド時のリポジトリの参照先をNexusに変更する

はじめに

Ionic(Cordova/Angular)アプリのAndroidビルドに時間がかかりすぎているせいで、Jenkinsのビルドが不安定になるという問題が発生しました。

そのため、mavenリポジトリの参照先をMaven Central リポジトリから、Nexusリポジトリに変更する対応を実施します。なお、AndroidビルドはGradleで実施していて、Nexusのリポジトリは社内向けのため認証があります。

環境

  • "cordova-android": "8.1.0",
  • "@ionic/angular": "5.1.0",
  • "@ionic/core": "5.1.0",

Cordova フックスクリプトを作成する

リポジトリの参照先をNexusに変更するには、platforms/android配下のbuild.gradleを修正する必要があります。ただし、build.gradleはビルド時に生成されるファイルであるため、手動で直接修正することはできません。

そのため、今回はCordovaフックスクリプトを作成することで、build.gradleファイルを修正します。

下記の通り、config.xml<platform name="android">配下に行を追記することで、Androidのビルド前にスクリプト(build_scripts/android-before-build.js)を呼び出すことができます。

config.xml

<platform name="android">
    <hook src="build_scripts/android-before-build.js" type="before_build" />
</platform>

続いてフックされるスクリプトを作成します。

build_scripts/android-before-build.js

/*
 * 最初に依存先を探すリポジトリをNexusに設定する。
 */
const fs = require('fs');
const path = require('path');
const async = require('async');

module.exports = context => {
  'use strict';
  const repoUrl =
    '[NexusリポジトリのURL]';
  const env = process.env;
  const gradleRepo = `maven {
     url "${repoUrl}"
     credentials {
       username "${env.NEXUS_USER}"
       password "${env.NEXUS_AUTH}"
     }
    }`;
  if (env.NEXUS_USER == null || env.NEXUS_AUTH == null) {
    return;
  }
  return new Promise((resolve, reject) => {
    const platformRoot = path.join(
      context.opts.projectRoot,
      'platforms/android'
    );

    const gradleFiles = findGradleFiles(platformRoot);

    // 最初に依存先を探すリポジトリをNexusに設定する。
    async.each(
      gradleFiles,
      function(file, callback) {
        let fileContents = fs.readFileSync(file, 'utf8');

        const insertLocations = [];
        const myRegexp = /\brepositories\s*{(.*)$/gm;
        let match = myRegexp.exec(fileContents);
        while (match != null) {
          if (match[1].indexOf(repoUrl) < 0) {
            insertLocations.push(match.index + match[0].length);
          }
          match = myRegexp.exec(fileContents);
        }

        if (insertLocations.length > 0) {
          insertLocations.reverse();
          insertLocations.forEach(location => {
            fileContents =
              fileContents.substr(0, location) +
              gradleRepo +
              fileContents.substr(location);
          });

          fs.writeFileSync(file, fileContents, 'utf8');
        }

        callback();
      },
      function(err) {
        if (err) {
          reject();
        } else {
          resolve();
        }
      }
    );
  });

  /**
   * gradleファイルのパスの一覧を返す
   *
   * @param {*} dir Androidプロジェクトのパス
   * @return {*} gradleファイルのパスの一覧
   */
  function findGradleFiles(dir) {
    let results = [];
    const list = fs.readdirSync(dir);
    list.forEach(fileName => {
      const filePath = path.join(dir, fileName);
      const stat = fs.statSync(filePath);
      if (stat && stat.isDirectory()) {
        results = results.concat(findGradleFiles(filePath));
      } else if (path.extname(filePath) === '.gradle') {
        results.push(filePath);
      }
    });
    return results;
  }
};

上記のスクリプトを実行するとbuild.gradleがこのようになります。

スクリプト実行前のbuild.gradle

f:id:l08084:20201210175410p:plain
スクリプト実行前のbuild.gradle

スクリプト実行後のbuild.gradle

f:id:l08084:20201210175727p:plain
スクリプト実行後のbuild.gradle

スクリプト実行前には、mavenCentralがリポジトリ探索の優先順位の一番だったのに、スクリプト実行後には、Nexusのリポジトリが探索の優先順位の一番になっていることがわかります。

スクリプトの処理内容について説明します。

上記のスクリプトは、repositoriesという文字列の後に下記の文言を追加するというものです。

    maven {
        url [NexusリポジトリURL]
        credentials {
          username [Nexusリポジトリ アカウントID]
          password [Nexusリポジトリ パスワード]
        }
    }

こうすることで、認証付きのNexusリポジトリにアクセスできるようになります。

参考記事

フック ガイド - Apache Cordova

android - Specify different repositories when using Cordova gradle wrapper - Stack Overflow

第8章 依存関係管理の基本

[CB-9704] Apache Cordova 5 does not support using a custom nexus repository for android builds - ASF JIRA

gradle + bitbucket + 社内向け (認証あり) maven リポジトリの設定手順 - Qiita

Where to put Gradle configuration (i.e. credentials) that should not be committed? - Stack Overflow

正規表現(RegExp) - とほほのWWW入門

【Angular】サロゲートペア(絵文字など)でもカウントできるValidator/Directiveの作成

はじめに

絵文字を使ってもバグらずに動くValidatorとDirectiveを作成します。

サロゲートペアについて

サロゲートペアとは、2つの文字コードを使って表現される文字を指し、通常の方法では正しく文字数をカウントできません。サロゲートペアには絵文字や一部の漢字が含まれます。次の例を見てください。

// 例1. 普通の文字
'A'.length; // 1
// 例2. 絵文字
'😀'.length; // 2
// 例3. 特殊な絵文字
'🏴󠁧󠁢󠁷󠁬󠁳󠁿'.length; // 14

例2の絵文字はサロゲートペアのため、2文字としてカウントされているのが分かります。また、例3のウェールズ国旗のような特殊な絵文字は、複数の絵文字で構成されているため、1文字で14文字としてカウントされています。

環境

TypeScriptベースのフレームワークであるAngularを使用します。

  • Angular: 8.2.14
  • stringz: 2.1.0

$ ng versionの実行結果

Angular CLI: 8.3.29
Node: 12.13.1
OS: darwin x64
Angular: 8.2.14
... animations, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.803.29
@angular-devkit/build-angular     0.803.29
@angular-devkit/build-optimizer   0.803.29
@angular-devkit/build-webpack     0.803.29
@angular-devkit/core              8.3.29
@angular-devkit/schematics        8.3.29
@angular/cli                      8.3.29
@ngtools/webpack                  8.3.29
@schematics/angular               8.3.29
@schematics/update                0.803.29
rxjs                              6.4.0
typescript                        3.5.3
webpack                           4.39.2

絵文字を正確にカウントできるライブラリ

絵文字をカウントする処理をスクラッチで書くと大変なので下記のライブラリを使用します。

github.com

上記のライブラリを使えば、通常の処理では難しい絵文字を含む文字列のカウントや、文字列操作を使用することができます。

  • 本ライブラリの使用例
import { substring, length } from 'stringz';

length('Iñtërnâtiônàlizætiøn☃💩'); // 22
substring('Emojis 👍🏽 are 🍆 poison. 🌮s are bad.', 7, 14); // "👍🏽 are 🍆"

Validatorの作成

上記のライブラリstringzを使用して、サロゲートペア(絵文字など)を含んだ文字列の最小文字数、最大文字数のバリデーターを作成します。 いわゆるAngularの公式バリデーターであるValidators.minLength(), Validators.maxLength()の絵文字に対応したバージョンになります。

surrogate-pair.validator.ts

import { length } from 'stringz';

import { Injectable } from '@angular/core';
import { AbstractControl, ValidatorFn } from '@angular/forms';

@Injectable()
export class SurrogatePairValidator {

  /**
   * サロゲートペア文字(絵文字など)も含む最小文字数
   *
   * @static
   * @param {number} minLength
   * @returns {ValidatorFn}
   * @memberof SurrogatePairValidator
   */
  public static minLength(minLength: number): ValidatorFn {
    return (control: AbstractControl): { [key: string]: any } => {
      const value = (control.value || '') + '';
      if (value === '') {
        return null;
      }
      return length(value) >= minLength
        ? null
        : { minlength: { requiredLength: minLength } };
    };
  }

  /**
   * サロゲートペア文字(絵文字など)も含む最大文字数
   *
   * @static
   * @param {number} maxLength
   * @return {*}  {ValidatorFn}
   * @memberof StringValidator
   */
  public static maxLength(maxLength: number): ValidatorFn {
    return (control: AbstractControl): { [key: string]: any } => {
      const value = (control.value || '') + '';
      if (value === '') {
        return null;
      }
      return length(value) <= maxLength
        ? null
        : { maxlength: { requiredLength: maxLength } };
    };
  }
}

上記のバリデーターのテストコードをjasmine + Karmaで書きます。

surrogate-pair.validator.spec.ts

import { Component, OnInit } from '@angular/core';
/**
 * This is unit test code for SurrogatePairValidator using karma.
 */
import {
  FormBuilder,
  FormGroup,
  ReactiveFormsModule
} from '@angular/forms';
import { SurrogatePairValidator } from './surrogate-pair.validator';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';

/**
 * Component that creates input field for test
 *
 * @export
 * @class RegisterComponent
 */
@Component({
  selector: 'app-register',
  template: `
    <form [formGroup]="form">
      <label>minLength</label>
      <input formControlName="minLength" />
      <label>maxLength</label>
      <input formControlName="maxLength" />
    </form>
  `
})
export class RegisterComponent implements OnInit {
  public form: FormGroup;

  constructor(private fb: FormBuilder) {}
  /**
   * initialize form
   *
   * @memberof RegisterComponent
   */
  public ngOnInit(): void {
    this.form = this.fb.group({
      minLength: [null, SurrogatePairValidator.minLength(5)],
      maxLength: [null, SurrogatePairValidator.maxLength(5)]
    });
  }
}

/**
 * Check if the method checks invalid_inputs and valid_inputs correctly
 *
 * @param {string} field_name
 * @param {RegisterComponent} component
 * @param {string[]} valid_inputs
 * @param {string[]} invalid_inputs
 */
function assertValues(
  field_name: string,
  component: RegisterComponent,
  valid_inputs: string[],
  invalid_inputs: string[]
): void {
  const control = component.form.controls[field_name];
  for (const invalid of invalid_inputs) {
    control.setValue(invalid);
    expect(control.valid).toBeFalsy();
  }
  for (const valid of valid_inputs) {
    control.setValue(valid);
    expect(control.valid).toBeTruthy();
  }
}

describe('SurrogatePairValidator', () => {
  let component: RegisterComponent;
  let fixture: ComponentFixture<RegisterComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ReactiveFormsModule, FormsModule],
      declarations: [RegisterComponent]
    });

    fixture = TestBed.createComponent(RegisterComponent);

    component = fixture.componentInstance;
    component.ngOnInit();
  });


  it('minLength', () => {
    const valid_inputs = ['12🐍4🏴󠁧󠁢󠁷󠁬󠁳󠁿', '1吉😀家😀🏴󠁧󠁢󠁷󠁬󠁳󠁿😱', '', null];
    const invalid_inputs = ['😀😀😀😀', '😀😀 😀', 'abce', '1234'];
    assertValues(
      'minLength',
      component,
      valid_inputs,
      invalid_inputs
    );
  });

  it('maxLength', () => {
    const valid_inputs = ['12🐍45', '1吉🏴󠁧󠁢󠁷󠁬󠁳󠁿😀', '', null];
    const invalid_inputs = ['😀😀😀😀😀😀', '😀😀 😀😀😀', 'abcedf', '123456'];
    assertValues(
      'maxLength',
      component,
      valid_inputs,
      invalid_inputs
    );
  });
});

上記のテストコードをng testコマンドで実行すると作成したバリデーターが正しく動くことがわかります。

f:id:l08084:20201206192241p:plain
絵文字の最小/最大文字数のバリデーターが正しく動いていることがわかる

Directiveの作成

続いて、input要素のmaxlength属性の絵文字対応バージョンを作成するために、指定文字数以上を入力したら、削除するようなDirectiveを作成します。

surrogate-pair-max-length.directive.ts

import { length, substring } from 'stringz';

import {
  Directive,
  ElementRef,
  HostListener,
  Input,
  Renderer2
} from '@angular/core';

/**
 * Surrogate pair max length directive.
 *
 * @export
 * @class SurrogatePairMaxLengthDirective
 */
@Directive({
  selector: '[appSurrogatePairMaxLength]'
})
export class SurrogatePairMaxLengthDirective {
  @Input() public appSurrogatePairMaxLength: number;

  constructor(private el: ElementRef, private renderer: Renderer2) {}

  /**
   * Handles input event.
   *
   * @param {*} event
   * @memberof SurrogatePairMaxLengthDirective
   */
  @HostListener('input', ['$event'])
  public onChange(event: any): void {
    if (length(event.target.value) > this.appSurrogatePairMaxLength) {
      this.renderer.setProperty(
        this.el.nativeElement,
        'value',
        substring(event.target.value, 0, this.appSurrogatePairMaxLength)
      );
    }
  }
}

下記のコードでinput要素に文字を入力するたびに、指定文字数よりも長い文字列が入力された場合は、Renderer2で余分な文字を削除した状態の文字列で置き換える、といった処理をしています。

  @HostListener('input', ['$event'])
  public onChange(event: any): void {
    if (length(event.target.value) > this.appSurrogatePairMaxLength) {
      this.renderer.setProperty(
        this.el.nativeElement,
        'value',
        substring(event.target.value, 0, this.appSurrogatePairMaxLength)
      );
    }
  }

上記のDirectiveを作成した後、下記のようなコードを書けば、3文字以上の文字を入力できないinput要素が完成します。

app.component.html

<input type="text" appSurrogatePairMaxLength="3">

f:id:l08084:20201206200119p:plain
3文字以上入力できない

参考サイト

GitHub - sallar/stringz: :100: Super fast unicode-aware string manipulation Javascript library

僕は、なぜ絵文字の長さが、直感に反するのか理解したい...!! - Qiita

A deep dive into Angular’s Renderer2.setValue method | by Konda Reddy Yaramala | JavaScript In Plain English | Medium

ionic2 - Ionic 3/Angular 2 - Renderer2.setValue() doesn't update the value of my input field - Stack Overflow

【Ionic v5】[個人開発]利用規約とプライバシーポリシー画面を作る

はじめに

Ionicで作成中の体重計アプリ「SpeedWeight」に利用規約とプライバシー画面を追加します。

環境

ハイブリットモバイルアプリ用フレームワークであるIonic(Angular)とFirebaseを使用してアプリを作成しています。

  • firebase@7.21.1

$ ionic infoコマンドの実行結果

$ ionic info

Ionic:

   Ionic CLI                     : 6.11.8 (/usr/local/lib/node_modules/@ionic/cli)
   Ionic Framework               : @ionic/angular 5.3.3
   @angular-devkit/build-angular : 0.1000.8
   @angular-devkit/schematics    : 10.0.8
   @angular/cli                  : 10.0.8
   @ionic/angular-toolkit        : 2.3.3

Capacitor:

   Capacitor CLI   : 2.4.1
   @capacitor/core : 2.4.1

Utility:

   cordova-res : not installed
   native-run  : not installed

System:

   NodeJS : v12.13.1 (/usr/local/bin/node)
   npm    : 5.6.0
   OS     : macOS Catalina

ログイン画面にリンクをつける

まず、ログイン画面に利用規約とプライバシーポリシー画面のリンクを追加します。

f:id:l08084:20201031190618p:plain

ログイン画面に追加した利用規約とプライバシーポリシー画面のリンクのコードは以下になります。

      <div class="notes">新規登録、ログインのどちらも上記のリンクから行うことができます。
        <a routerLink="/terms-of-service">
          利用規約
        </a><a routerLink="/privacy-policy">
          プライバシーポリシー
        </a>に同意したうえでログインしてください。
      </div>

リンクを追加したログイン画面のコードの全体像は以下になります。

「SpeedWeight」のログイン画面

利用規約画面を作成する

f:id:l08084:20201031191714p:plain
作成した利用規約画面

汎用的な利用規約の雛形(ひな型) | Webサイトの利用規約(無料テンプレート・商用利用可)

上記リンクの利用規約の雛形を参考にして、利用規約画面を作成します。

「SpeedWeight」の利用規約

プライバシーポリシー画面を作成する

f:id:l08084:20201031191759p:plain
作成したプライバシーポリシー画面

プライバシーポリシーの雛形(ひな型) | Webサイトの利用規約(無料テンプレート・商用利用可)

上記リンクのプライバシーポリシーの雛形を参考にして、プライバシーポリシー画面を作成します。

「SpeedWeight」のプライバシーポリシー

参考サイト

Webサービス個人開発するなら知りたい利用規約とプライバシーポリシーの作り方 - Qiita

汎用的な利用規約の雛形(ひな型) | Webサイトの利用規約(無料テンプレート・商用利用可)

プライバシーポリシーの雛形(ひな型) | Webサイトの利用規約(無料テンプレート・商用利用可)

個人開発者がサービスリリースに際してやることリスト - Qiita

個人開発アプリのプライバシーポリシーを参考にしてみる – 輝く僕らの学費

ion-router-link - Ionic Documentation