中安拓也のブログ

プログラミングについて書くブログ。 Twitterやってます @l08084

【Ionic v5】Alert/Modal同士でz-indexを交換する

環境

TypeScriptベースのフレームワークであるAngularと、iOS/AndroidのハイブリッドモバイルアプリケーションのフレームワークであるIonicを使用しています。

  • "rxjs": "~6.6.0",
  • "@angular/fire": "^6.1.4"
  • "firebase": "^8.3.1"
  • "semver": "^7.3.5"

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

Cordova:

   Cordova CLI       : 8.0.0
   Cordova Platforms : none
   Cordova Plugins   : no whitelisted plugins (1 plugins total)

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        : 6.14.12
   OS         : macOS Catalina
   Xcode      : Xcode 12.0.1 Build version 12A7300

Alert/Modal同士でz-indexを交換する

Ionicのアラート/モーダルはz-indexを交換することができるため、アラート/モーダル同士の奥行きの位置を入れ替えることができます。

  /**
   * アラート/モーダルの奥行きの位置を交換する
   *
   * @private
   * @memberof VersionCheckService
   */
  private swapPositions(): void {
    [this.versionUpAlert.style.zIndex, this.maintenanceAlert.style.zIndex] = [
      this.maintenanceAlert.style.zIndex,
      this.versionUpAlert.style.zIndex,
    ];
  }

例えば、下記の画像では、バージョンアップのアラートとメンテナンスのアラートが重なって表示されていて、あとに表示されたバージョンアップのアラートが前面に表示されています。

f:id:l08084:20210411161642p:plain
バージョンアップのアラートが前面に表示されている

この状態で先ほど作成したswapPositions()を呼び出すと、アラートの位置が交換されて、メンテナンスのアラートが前面に表示されます。(隠れて見えませんが背後にバージョンアップのアラートが表示されています)

f:id:l08084:20210411163147p:plain
メンテナンスのアラートが前に来る

該当コードの全体像は下記となります。

import { Injectable } from '@angular/core';
import { AngularFireDatabase } from '@angular/fire/database';
import { Observable } from 'rxjs';
import { Maintenance } from '../model/maintenance.model';
import { Version } from '../model/version.model';
import * as semver from 'semver';
import { AlertController } from '@ionic/angular';
@Injectable({
  providedIn: 'root',
})
export class VersionCheckService {
  // このアプリのバージョン
  private readonly appVersion = '1.0.0';

  private maintenance$: Observable<Maintenance>;
  private version$: Observable<Version>;
  private maintenanceAlert: HTMLIonAlertElement;
  private versionUpAlert: HTMLIonAlertElement;

  constructor(
    private db: AngularFireDatabase,
    private alertController: AlertController
  ) {}

  /**
   * 初期設定
   *
   * @memberof VersionCheckService
   */
  public initSetting(): void {
    // Realtime Databaseからデータを取得
    this.maintenance$ = this.db
      .object<Maintenance>('maintenance')
      .valueChanges();
    this.version$ = this.db.object<Version>('version').valueChanges();

    this.maintenance$.subscribe(
      async (maintenance: Maintenance) =>
        await this.checkMaintenance(maintenance)
    );
    this.version$.subscribe(
      async (version: Version) =>
        await this.checkVersion(this.appVersion, version)
    );
  }

  /**
   * メンテナンスポップアップを表示する。
   *
   * @private
   * @param {Maintenance} maintenance
   * @returns {Promise<void>}
   * @memberof VersionCheckService
   */
  private async checkMaintenance(maintenance: Maintenance): Promise<void> {
    if (!maintenance) {
      return;
    }

    if (!maintenance.maintenanceFlg) {
      // メンテナンスフラグがOFFだったら処理を中断する
      if (this.maintenanceAlert) {
        // メンテナンスメッセージが開かれている場合は閉じる
        await this.maintenanceAlert.dismiss();
        this.maintenanceAlert = undefined;
      }
      return;
    }

    // メンテナンスメッセージを表示する
    this.maintenanceAlert = await this.alertController.create({
      header: maintenance.title,
      message: maintenance.message,
      backdropDismiss: false, // 背景をクリックしても閉じない
    });
    await this.maintenanceAlert.present();
  }

  /**
   * 強制バージョンアップメッセージを表示する。
   *
   * @private
   * @param {string} appVersion
   * @param {Version} version
   * @returns
   * @memberof VersionCheckService
   */
  private async checkVersion(appVersion: string, version: Version) {
    if (!version || !version.minimumVersion) {
      return;
    }

    if (semver.gte(appVersion, version.minimumVersion)) {
      // 最低バージョンよりもアプリのバージョンが高かったら処理を中断する
      if (this.versionUpAlert) {
        // 強制バージョンアップメッセージが開かれている場合は閉じる
        await this.versionUpAlert.dismiss();
        this.versionUpAlert = undefined;
      }
      return;
    }

    // 強制バージョンアップメッセージを表示する
    this.versionUpAlert = await this.alertController.create({
      header: version.title,
      message: version.message,
      backdropDismiss: false, // 背景をクリックしても閉じない
    });
    await this.versionUpAlert.present();
  }

  /**
   * アラート/モーダルの奥行きの位置を交換する
   *
   * @private
   * @memberof VersionCheckService
   */
  private swapPositions(): void {
    [this.versionUpAlert.style.zIndex, this.maintenanceAlert.style.zIndex] = [
      this.maintenanceAlert.style.zIndex,
      this.versionUpAlert.style.zIndex,
    ];
  }
}

Alert/Modalのz-indexを参照できるのはなぜか?

そもそも、なぜIonicのアラート/モーダルで、z-indexを参照・更新できるのか?という点について説明します。

Ionicのアラートの型はHTMLIonAlertElement、モーダルの型はHTMLIonModalElementに設定されています。

HTMLIonAlertElementHTMLIonModalElementHTMLElementを継承しているため、HTMLElement.styleによって、z-index含むスタイル関連のメソッドやプロパティの情報を取得することができる、というわけです。

おわりに

IonicのコンポーネントってStencilで書かれているWeb Componentsなんですね......知らなかった。コントリビュートしやすそう

参考サイト

ElementCSSInlineStyle.style - Web API | MDN

Stencilを使ってWebComponentを作ってみる - Qiita

Web Components | MDN

Using custom elements - Web Components | MDN

型付きコンポーネント - Stencil

ionic-framework/alert.tsx at 8e0e5da7407adecb7471b3a6b0ac059337761355 · ionic-team/ionic-framework · GitHub

ionic-framework/core/src/components/modal at 8e0e5da7407adecb7471b3a6b0ac059337761355 · ionic-team/ionic-framework · GitHub

【RxJS】Subjectを使って好きなタイミングでデータを流す

はじめに

今回は、前回の記事で作成した強制バージョンアップとメンテナンスのアラートを表示する機能に、下記の機能を追加することでリアクティブ・プログラミング用のライブラリであるRxJSのSubjectの使用方法について説明します。

  • 今回追加する機能
    • 強制バージョンアップのアラートの表示を、メンテナンスのアラートの表示よりも優先する機能
      • メンテナンスのアラートを表示しているときに、強制バージョンアップのアラートを表示する場合は、メンテナンスのアラートを閉じる
      • 強制バージョンアップのアラートを表示している時はメンテナンスのメッセージを表示しない

ちなみに、上記の例だとSubjectを使わなくてもすっきりしたコードが書けます。まあ、RxJSの勉強ということで。。。

環境

TypeScriptベースのフレームワークであるAngularと、iOS/AndroidのハイブリッドモバイルアプリケーションのフレームワークであるIonicを使用しています。

  • "rxjs": "~6.6.0",
  • "@angular/fire": "^6.1.4"
  • "firebase": "^8.3.1"
  • "semver": "^7.3.5"

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

Cordova:

   Cordova CLI       : 8.0.0
   Cordova Platforms : none
   Cordova Plugins   : no whitelisted plugins (1 plugins total)

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        : 6.14.12
   OS         : macOS Catalina
   Xcode      : Xcode 12.0.1 Build version 12A7300

アラート表示の実装

前回の記事で作成した強制バージョンアップとメンテナンスのアラートを表示する実装です。

この実装にSubjectを使用して、強制バージョンアップのアラートの表示を、メンテナンスのアラートの表示よりも優先する機能を付けていきます。

src/app/services/version-check.service.ts

import { Injectable } from '@angular/core';
import { AngularFireDatabase } from '@angular/fire/database';
import { Observable } from 'rxjs';
import { Maintenance } from '../model/maintenance.model';
import { Version } from '../model/version.model';
import * as semver from 'semver';
import { AlertController } from '@ionic/angular';
@Injectable({
  providedIn: 'root',
})
export class VersionCheckService {
  // このアプリのバージョン
  private readonly appVersion = '1.0.0';

  private maintenance$: Observable<Maintenance>;
  private version$: Observable<Version>;
  private maintenanceAlert: HTMLIonAlertElement;
  private versionUpAlert: HTMLIonAlertElement;

  constructor(
    private db: AngularFireDatabase,
    private alertController: AlertController
  ) {}

  /**
   * 初期設定
   *
   * @memberof VersionCheckService
   */
  public initSetting(): void {
    // Realtime Databaseからデータを取得
    this.maintenance$ = this.db
      .object<Maintenance>('maintenance')
      .valueChanges();
    this.version$ = this.db.object<Version>('version').valueChanges();

    this.maintenance$.subscribe(
      async (maintenance: Maintenance) =>
        await this.checkMaintenance(maintenance)
    );
    this.version$.subscribe(
      async (version: Version) =>
        await this.checkVersion(this.appVersion, version)
    );
  }

  /**
   * メンテナンスポップアップを表示する。
   *
   * @private
   * @param {Maintenance} maintenance
   * @returns {Promise<void>}
   * @memberof VersionCheckService
   */
  private async checkMaintenance(maintenance: Maintenance): Promise<void> {
    if (!maintenance) {
      return;
    }

    if (!maintenance.maintenanceFlg) {
      // メンテナンスフラグがOFFだったら処理を中断する
      if (this.maintenanceAlert) {
        // メンテナンスメッセージが開かれている場合は閉じる
        await this.maintenanceAlert.dismiss();
        this.maintenanceAlert = undefined;
      }
      return;
    }

    // メンテナンスメッセージを表示する
    this.maintenanceAlert = await this.alertController.create({
      header: maintenance.title,
      message: maintenance.message,
      backdropDismiss: false, // 背景をクリックしても閉じない
    });
    await this.maintenanceAlert.present();
  }

  /**
   * 強制バージョンアップメッセージを表示する。
   *
   * @private
   * @param {string} appVersion
   * @param {Version} version
   * @returns
   * @memberof VersionCheckService
   */
  private async checkVersion(appVersion: string, version: Version) {
    if (!version || !version.minimumVersion) {
      return;
    }

    if (semver.gte(appVersion, version.minimumVersion)) {
      // 最低バージョンよりもアプリのバージョンが高かったら処理を中断する
      if (this.versionUpAlert) {
        // 強制バージョンアップメッセージが開かれている場合は閉じる
        await this.versionUpAlert.dismiss();
        this.versionUpAlert = undefined;
      }
      return;
    }

    // 強制バージョンアップメッセージを表示する
    this.versionUpAlert = await this.alertController.create({
      header: version.title,
      message: version.message,
      backdropDismiss: false, // 背景をクリックしても閉じない
    });
    await this.versionUpAlert.present();
  }
}

ObservableではなくSubjectが必要になるタイミング

AngularのRxJSを使ってデータの受け渡しをする - Qiita

AngularでObservableを使うとき、もう一つ抑えておきたいクラスがあります。 上記のコードではObservableクラスのインスタンスを作成したタイミングでしかデータを流すことができず、クリックなどのイベントをトリガーとしてデータを処理したいような場合には向いていません。 そんな時に使用するのがSubjectクラスです。 SubjectクラスのインスタンスはObservableとobserverの2つの役割を同時に担うことができ、任意のタイミングでデータを流すことができます。

上記の記事で記載されている通り、SubjectはObservableと違って、next()メソッドを呼び出すことができるので、任意のタイミングでデータを流すことができます。

メンテナンスよりも強制バージョンアップのアラートを優先して表示する

さて、Subjectを使用して、メンテナンスのアラートを表示しているときに、強制バージョンアップのアラートを表示する場合は、メンテナンスのアラートを閉じる機能を実装するには、下記のようにする必要があります。

  • 強制バージョンアップのアラートが表示されたときには、Subject(isShowVersionUpAlert)のnext(true)を呼び出してストリームにtrueの値を流す
  • 強制バージョンアップのアラートが閉じたときには、Subject(isShowVersionUpAlert)のnext(false)を呼び出してストリームにfalseの値を流す
  • 下記の処理でストリームに流された値を受け取る
    this.isShowVersionUpAlert
      .pipe(
        filter(
          (isShowVersionUp: boolean) =>
            isShowVersionUp && !!this.maintenanceAlert
        )
      )
      .subscribe(async () => {
        await this.maintenanceAlert.dismiss();
        this.maintenanceAlert = undefined;
      });

上記の処理では、Subject(isShowVersionUpAlert)ストリームにデータが流れたときに、強制バージョンアップが表示されている(isShowVersionUp=true) かつ、メンテナンスアラートが表示されている(!!this.maintenanceAlert=true)場合は、メンテナンスのアラートを閉じるという処理をしています。

上記の処理を追加した、コードの全体像は下記のようになります。

src/app/services/version-check.service.ts

import { Injectable } from '@angular/core';
import { AngularFireDatabase } from '@angular/fire/database';
import { Observable, Subject } from 'rxjs';
import { Maintenance } from '../model/maintenance.model';
import { Version } from '../model/version.model';
import * as semver from 'semver';
import { AlertController } from '@ionic/angular';
import { filter } from 'rxjs/operators';

@Injectable({
  providedIn: 'root',
})
export class VersionCheckService {
  private readonly appVersion = '1.0.0';

  private maintenance$: Observable<Maintenance>;
  private version$: Observable<Version>;
  private maintenanceAlert: HTMLIonAlertElement;
  private versionUpAlert: HTMLIonAlertElement;

  // add this!
  private isShowVersionUpAlert = new Subject<boolean>();

  constructor(
    private db: AngularFireDatabase,
    private alertController: AlertController
  ) {}

  /**
   * 初期設定
   *
   * @memberof VersionCheckService
   */
  public initSetting(): void {
    this.maintenance$ = this.db
      .object<Maintenance>('maintenance')
      .valueChanges();
    this.version$ = this.db.object<Version>('version').valueChanges();

    this.maintenance$.subscribe(
      async (maintenance: Maintenance) =>
        await this.checkMaintenance(maintenance)
    );
    this.version$.subscribe(
      async (version: Version) =>
        await this.checkVersion(this.appVersion, version)
    );

    // add this!
    this.isShowVersionUpAlert
      .pipe(
        filter(
          (isShowVersionUp: boolean) =>
            isShowVersionUp && !!this.maintenanceAlert
        )
      )
      .subscribe(async () => {
        await this.maintenanceAlert.dismiss();
        this.maintenanceAlert = undefined;
      });
  }

  /**
   * メンテナンスポップアップを表示する。
   *
   * @private
   * @param {Maintenance} maintenance
   * @returns {Promise<void>}
   * @memberof VersionCheckService
   */
  private async checkMaintenance(maintenance: Maintenance): Promise<void> {
    if (!maintenance) {
      return;
    }

    // add conditions '|| !!this.versionUpAlert'
    if (!maintenance.maintenanceFlg || !!this.versionUpAlert) {
      if (this.maintenanceAlert) {
        await this.maintenanceAlert.dismiss();
        this.maintenanceAlert = undefined;
      }
      return;
    }

    this.maintenanceAlert = await this.alertController.create({
      header: maintenance.title,
      message: maintenance.message,
      backdropDismiss: false,
    });
    await this.maintenanceAlert.present();
  }

  /**
   * 強制バージョンアップメッセージを表示する。
   *
   * @private
   * @param {string} appVersion
   * @param {Version} version
   * @returns
   * @memberof VersionCheckService
   */
  private async checkVersion(appVersion: string, version: Version) {
    if (!version || !version.minimumVersion) {
      return;
    }

    if (semver.gte(appVersion, version.minimumVersion)) {
      if (this.versionUpAlert) {
        await this.versionUpAlert.dismiss();
        this.versionUpAlert = undefined;

        // add this!
        this.isShowVersionUpAlert.next(false);
      }
      return;
    }

    this.versionUpAlert = await this.alertController.create({
      header: version.title,
      message: version.message,
      backdropDismiss: false,
    });

    // add this!
    this.isShowVersionUpAlert.next(true);

    await this.versionUpAlert.present();
  }
}

動作確認

上記のコードを実際に動かしてみます。

まず、メンテナンスのアラートを表示します。

f:id:l08084:20210404155530p:plain
メンテナンスのアラートが表示されている

続いて、強制バージョンアップのアラートを表示すると、メンテナンスのアラートが閉じて、強制バージョンアップのアラートだけが表示されることがわかります。期待結果通りの動きです。

f:id:l08084:20210404155604p:plain
強制バージョンアップのアラートだけが表示される

おまけ: どうしてSubjectにasObservableが必要なのか

上記の実装をしている時に、SubjcetにasObservable()メソッドが用意されているのはなぜなのか?という疑問を持ちました。

asObservable()はSubjectをObservableに変換するメソッドですが、Subjectは元々Observableの機能を持っているため、SubjectからObservableに変換する意味がわからなかったためです。

Why asObservable with Subjects?. When I started learning angular I read… | by Mamta Bisht | Medium

この疑問を解消するのに、上記の記事の解説が参考になりました。

SubjectからasObservable()を呼び出してObservableに変換するのは、機能を制限するため、とのことです。

Subjectを他クラスに渡すときなどに、そのまま渡すとnext()メソッドが使えるので、他のクラスからも値を流すことが可能になってしまい、意図していない値の流され方をされてしまうかもしれません。

そのような事態を防ぐために、asObservable()でSubjectをObservableに変換してnext()メソッドを使えない状態にしてから渡したほうが良い、とのことでした。

参考

Why asObservable with Subjects?. When I started learning angular I read… | by Mamta Bisht | Medium

[Angular] サービスを使用してデータをコンポーネント間で共有する - Qiita

AngularのRxJSを使ってデータの受け渡しをする - Qiita

RxJS を学ぼう #5 – Subject について学ぶ / Observable × Observer – PSYENCE:MEDIA

Angular + Firebase Realtime Databaseでメンテナンスと強制バージョンアップのポップアップを表示する

はじめに

今回は、Angular(Ionic)で作ったアプリからFirebase Realtime Databaseを参照して、メンテナンスと強制バージョンアップを知らせるポップアップを表示する機能を作成します。

Firebaseが提供しているデータベースについて

まず、Firebaseが提供している二つのデータベースの違いについて説明します。

  • Cloud Firestore
    • データをドキュメントのコレクションとして扱う。多彩なクエリを使えるため検索に強く、Realtime Databaseよりも複雑で階層的なデータを扱いやすい
  • Realtime Database
    • データを単一のJSONツリーとして扱う。Cloud Firestoreよりもレイテンシーが低いため、データの同期が早い

Realtime Databaseではなく、基本的に後続サービスのCloud Firestoreを使ってください、と書かれている記事が多かったんですが、今回はデータのread/writeのスピードがより早いRealtime Databaseを採用します。

環境

TypeScriptベースのフレームワークであるAngularと、iOS/AndroidのハイブリッドモバイルアプリケーションのフレームワークであるIonicを使用しています。

  • "@angular/fire": "^6.1.4"
  • "firebase": "^8.3.1"
  • "semver": "^7.3.5"

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

Cordova:

   Cordova CLI       : 8.0.0
   Cordova Platforms : none
   Cordova Plugins   : no whitelisted plugins (1 plugins total)

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        : 6.14.12
   OS         : macOS Catalina
   Xcode      : Xcode 12.0.1 Build version 12A7300

環境構築

環境構築をしていきます。Firebaseのプロジェクトを作成した後、今回はWebアプリを作成するため、プラットフォームのウェブのアプリを追加します。

f:id:l08084:20210327175441p:plain
Firebaseのプロジェクトを作成する

続いて、プラットフォーム「ウェブアプリ」のSDKスニペットのfirebaseConfigの内容をAngular(Ionic)プロジェクトのsrc/environments/environment.tsに転記します。

f:id:l08084:20210327180409p:plain
編集後のenvironment.ts

AngularからFirebaseに接続する用途のライブラリである、AngularFireをnpmインストールします。

今回作成しているアプリはAngular CLIから作ったプロジェクトではなく、AngularベースのIonicプロジェクトであるため、公式が推奨しているng addコマンドではなく、下記のコマンドを使用します。

npm i firebase @angular/fire

app.module.tsimports: []AngularFireModule.initializeApp(environment.firebaseConfig)を追加します。

app.module.ts

import { AngularFireModule } from '@angular/fire';
import { environment } from 'src/environments/environment';
@NgModule({
  // ...省略
  imports: [
    // add this
    AngularFireModule.initializeApp(environment.firebaseConfig),
  ],
})
export class AppModule {}

強制バージョンアップメッセージの表示判定に使用する、セマンティック バージョニングを比較することができるライブラリ、node-semverもインストールします。

npm i semver

補足

今回はブラウザ上でしかアプリを動かさないので使えませんが、モバイルアプリ(iOS/Android)としても動かすときは、アプリバージョンを取得するライブラリであるcordova-plugin-app-versionを使ったりします。

ionic cordova plugin add cordova-plugin-app-version
npm i @ionic-native/app-version
npm i @ionic-native/core

Realtime Databaseのデータ構造を構築する

Realtime Databaseのデータ構造をJSONファイルとして作成します。

今回は、メンテナンスと強制バージョンアップのポップアップを表示したいので、下記のような構造のJSONファイルにしました。

{
  "maintenance": {
    "maintenanceFlg": false,
    "message": "メンテナンスのため一時サービスを停止しております。<br/>しばらくお待ちください。",
    "title": "メンテナンス"
  },
  "version": {
    "message": "最新版のアプリがリリースされました。<br/>バージョンアップをお願いいたします。",
    "minimumVersion": "0.0.3",
    "title": "バージョンアップ"
  }
}

上記で作成したJSONファイルをRealtime Databaseにインポートします。

f:id:l08084:20210329162221p:plain
「JSONをインポート」を選択する

JSONファイルをインポートすると、下記画像のように、Realtime Database上にデータ構造が作成されることを確認できます。

f:id:l08084:20210329162340p:plain
JSONインポート後のRealtime Database

メンテナンスと強制バージョンアップのポップアップを表示する

作成したRealtime Databaseのデータ構造を使って、ポップアップを表示するサービスクラスVersionCheckServiceを作成していきます。

src/app/services/version-check.service.ts

import { Injectable } from '@angular/core';
import { AngularFireDatabase } from '@angular/fire/database';
import { Observable } from 'rxjs';
import { Maintenance } from '../model/maintenance.model';
import { Version } from '../model/version.model';
import * as semver from 'semver';
import { AlertController } from '@ionic/angular';
@Injectable({
  providedIn: 'root',
})
export class VersionCheckService {
  // このアプリのバージョン
  private readonly appVersion = '1.0.0';

  private maintenance$: Observable<Maintenance>;
  private version$: Observable<Version>;
  private maintenanceAlert: HTMLIonAlertElement;
  private versionUpAlert: HTMLIonAlertElement;

  constructor(
    private db: AngularFireDatabase,
    private alertController: AlertController
  ) {}

  /**
   * 初期設定
   *
   * @memberof VersionCheckService
   */
  public initSetting(): void {
    // Realtime Databaseからデータを取得
    this.maintenance$ = this.db
      .object<Maintenance>('maintenance')
      .valueChanges();
    this.version$ = this.db.object<Version>('version').valueChanges();

    this.maintenance$.subscribe(
      async (maintenance: Maintenance) =>
        await this.checkMaintenance(maintenance)
    );
    this.version$.subscribe(
      async (version: Version) =>
        await this.checkVersion(this.appVersion, version)
    );
  }

  /**
   * メンテナンスポップアップを表示する。
   *
   * @private
   * @param {Maintenance} maintenance
   * @returns {Promise<void>}
   * @memberof VersionCheckService
   */
  private async checkMaintenance(maintenance: Maintenance): Promise<void> {
    if (!maintenance) {
      return;
    }

    if (!maintenance.maintenanceFlg) {
      // メンテナンスフラグがOFFだったら処理を中断する
      if (this.maintenanceAlert) {
        // メンテナンスメッセージが開かれている場合は閉じる
        await this.maintenanceAlert.dismiss();
        this.maintenanceAlert = undefined;
      }
      return;
    }

    // メンテナンスメッセージを表示する
    this.maintenanceAlert = await this.alertController.create({
      header: maintenance.title,
      message: maintenance.message,
      backdropDismiss: false, // 背景をクリックしても閉じない
    });
    await this.maintenanceAlert.present();
  }

  /**
   * 強制バージョンアップメッセージを表示する。
   *
   * @private
   * @param {string} appVersion
   * @param {Version} version
   * @returns
   * @memberof VersionCheckService
   */
  private async checkVersion(appVersion: string, version: Version) {
    if (!version || !version.minimumVersion) {
      return;
    }

    if (semver.gte(appVersion, version.minimumVersion)) {
      // 最低バージョンよりもアプリのバージョンが高かったら処理を中断する
      if (this.versionUpAlert) {
        // 強制バージョンアップメッセージが開かれている場合は閉じる
        await this.versionUpAlert.dismiss();
        this.versionUpAlert = undefined;
      }
      return;
    }

    // 強制バージョンアップメッセージを表示する
    this.versionUpAlert = await this.alertController.create({
      header: version.title,
      message: version.message,
      backdropDismiss: false, // 背景をクリックしても閉じない
    });
    await this.versionUpAlert.present();
  }
}

メンテナンスメッセージ用のクラスを作成します。

src/app/model/maintenance.model.ts

export interface Maintenance {
  maintenanceFlg: boolean;
  message: string;
  title: string;
}

強制バージョンアップメッセージ用のクラスを作成します。

src/app/model/version.model.ts

export interface Version {
  message: string;
  minimumVersion: string;
  title: string;
}

最後にAppComponentからVersionCheckServiceからinitSetting()を呼び出してあげれば実装は終了です。

src/app/app.component.ts

// ...省略
export class AppComponent implements OnInit {
  constructor(private versionCheckService: VersionCheckService) {}

  public ngOnInit(): void {
    this.versionCheckService.initSetting();
  }
}

動作確認

上記で作成したコードの動作確認をします。

メンテナンスメッセージの表示

Realtime Database上のmaintenanceFlgtrueに書き換えると、アプリ上でメンテナンスメッセージが表示されます。

f:id:l08084:20210329162506p:plain
maintenanceFlgをtrueに書き換える

f:id:l08084:20210329160227p:plain
アプリ上でメンテナンスメッセージが表示される

強制バージョンアップメッセージの表示

アプリのバージョン(1.0.0)よりも大きいバージョンを、Realtime Database上のminimumVersionに上書くと、アプリ上で強制バージョンアップメッセージが表示されます。

f:id:l08084:20210329160822p:plain
minimumVersionの値を書き換える

f:id:l08084:20210329160902p:plain
強制バージョンアップメッセージが表示される

参考サイト

データベースを選択: Cloud Firestore または Realtime Database  |  Firebase

GitHub - angular/angularfire: The official Angular library for Firebase.

App Version - Ionic Documentation

GitHub - npm/node-semver: The semver parser for node (the one npm uses)

GitHub - ionic-team/ionic-native: Native features for mobile apps built with Cordova/PhoneGap and open web technologies. Complete with TypeScript support. The successor to ngCordova. Pairs exquisitely with a nice bottle of Ionic Framework.

The target entry-point -has missing dependencies: - Ionic Native - Ionic Forum

TypeScript eqの例、semver.eq TypeScriptの例 - HotExamples

ion-alert: Ionic Framework API Docs

セマンティック バージョニング 2.0.0 | Semantic Versioning

【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

参考サイト

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

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