Cordovaアプリの技術的負債を頑張って解消した話

## はじめに

この記事は[ラクス Advent Calendar 2021](https://qiita.com/advent-calendar/2021/rakus)の14日目の記事です。
なんだかんだで4年連続4回目の参加になりました。感慨深い。

今年は、cordovaを利用したAndroidアプリの技術的負債を解消した話をしようと思います。

## 技術的負債を解消しよう

現在私は主に、以下3つのプロダクトの開発に携わっています。

* Androidアプリ(Kotlin)
* Androidアプリ(Apache Cordova)
* APIサーバー(Spring Boot)

で、まあどのプロダクトもそれなりにフレームワークやライブラリを使用しているのですが、
今まではそれらに脆弱性が見つかるなどがない限り、中々バージョンアップの機会がありませんでした。

今のところはまだなんとかなっていますが、今後重大な脆弱性が発見されてから急いでアップデートしようとしても
色々変わりすぎてて中々対応できない！ということになりかねないので、これを機に各プロダクトをさっぱり最新化して技術的負債を解消しようということになりました。

今回はcordovaアプリの話です。KotlinやSpringの話はまた機会があれば。

### Node.jsのアップデート

cordova(Apache Cordova)は、HTMLやCSS,JSといったWeb技術を利用してアプリケーションを開発できるフレームワークです。

https://cordova.apache.org/

cordovaやその他依存ライブラリのインストールにはnpm、つまりNode.jsを利用していますので、まずはここのバージョンを確認してみます。

```bash
$ node -v
v10.16.0
```

**古い。**リリースノートを見るとv10.16.0のリリースは2019-05-28と出たので、2年近く更新していないことになります。

というわけでこれをバージョンアップするところから始めましょう。この対応時点での最新バージョンはv17.0.1だったので、とりあえず安易に最新版まで上げてみました。

```shell-session
$ nodebrew install-binary v17.0.1
Fetching: https://nodejs.org/dist/v17.0.1/node-v17.0.1-darwin-x64.tar.gz
########################################################################################################################### 100.0%
Installed successfully
$ nodebrew use v17.0.1
use v17.0.1 
```

これでNode.jsのv17.0.1、npmはv8.1.0を利用できるようになりました。早速`npm install`で依存ライブラリをインストールします。

```shell-session
$ npm install
npm WARN old lockfile 
npm WARN old lockfile The package-lock.json file was created with an old version of npm,
npm WARN old lockfile so supplemental metadata must be fetched from the registry.
npm WARN old lockfile 
npm WARN old lockfile This is a one-time fix-up, please be patient...
npm WARN old lockfile

# (中略)

> your_app@1.0.0 postinstall
> ./update-platform.sh


> your_app@1.0.0 gulp
> gulp

fs.js:47
} = primordials;
    ^

ReferenceError: primordials is not defined
    at fs.js:47:5
    at req_ (/Users/your_name/workspace/your_app/node_modules/natives/index.js:143:24)
    at Object.req [as require] (/Users/your_name/workspace/your_app/node_modules/natives/index.js:55:10)
    at Object.<anonymous> (/Users/your_name/workspace/your_app/node_modules/vinyl-fs/node_modules/graceful-fs/fs.js:1:37)
    at Module._compile (node:internal/modules/cjs/loader:1095:14)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1147:10)
    at Module.load (node:internal/modules/cjs/loader:975:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Module.require (node:internal/modules/cjs/loader:999:19)
    at require (node:internal/modules/cjs/helpers:102:18)
```

**ですよねー。**

#### 何が起こったのか？

このプロダクトのpackage.jsonでは、[postinstall](https://docs.npmjs.com/cli/v8/using-npm/scripts)を使用して`npm install`コマンドの実行直後に以下のスクリプトを実行するようになっています。

```shell:update-platform.sh
#!/usr/bin/env bash

gulp
cordova platform remove android --nosave
cordova platform add android --nosave
```

まあざっくり言えばgulpでコード圧縮を行なった後にplatformの再生成を行なっているのですが、このgulpのバージョンが3系であったためにNode.jsのバージョンと噛み合わなかったとのこと。

https://qiita.com/1104crf14/items/a0d648da38353e772207

というわけで、gulpも4系に上げていきます。

### gulpのアップデート

プロダクトで使用していたgulpのバージョンはv3.9.0。
最新版はこの対応時点でv4.0.2だったので、package.jsonを書き換えておきます。

```diff_json:package.json
-   "gulp": "^3.9.0",
+   "gulp": "^4.0.2",
```

メジャーバージョンを上げるので当然このままでは正常に動作するわけもなく、4系への移行方法を調査してgulpfileを書き換えていきます。
主に修正が必要だった点は以下の2箇所でした。先人の知恵に感謝。

* gulp.taskやgulp.watchの第二引数に配列を使えなくなった
* gulp.taskに渡す関数に引数を追加し、コールバックメソッドを最後に呼び出す必要ができた

https://qiita.com/tatsuo-iriyama/items/08ba4bd621b7fdedcc4e

```diff_javascript:gulpfile.js
- gulp.task('js', ['js.platformOverrides'], function () {
+ gulp.task('js', gulp.series('js.platformOverrides', function (done) {
    // 中略
-    return gulp.src(srcset)
+    gulp.src(srcset)
        // 中略
        .pipe(gulp.dest('www/js'));
- });
+   done();
+ }));
```

こんな感じの修正を全体に加えると、無事gulpくんはエラーを吐かなくなりました。
早速、`npm install`から`gulp && cordova run android --device -d`で実機デバッグすると...

<img width="30%" alt="Screenshot_20211209-114901.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308803/90bd7642-4c7c-d450-d403-b494a7865c0d.png">

**何も表示されません。**ﾅﾝﾃｺｯﾀｲ

#### 何が起こったのか？

何分エラーは一切起こっていないのでアホみたいに調査に時間がかかりましたが、原因はやっぱりgulp(の関連ライブラリ)にありました。

https://github.com/miickel/gulp-angular-templatecache/issues/164

このプロダクトはgulp-angular-templatecacheというライブラリを使用しています。
これはAngularJS+gulpでJSやCSSをビルドする際に、指定したディレクトリ内のhtmlファイルを一括で圧縮し、jsファイルとして生成してくれるpluginなのですが...

```js:templates_gulp3.js
angular.module('your_app').run(['$templateCache', function($templateCache) {
$templateCache.put('a.html','[a.htmlの中身]');
$templateCache.put('b.html','[b.htmlの中身]');
$templateCache.put('c.html','[c.htmlの中身]');
$templateCache.put('d.html','[d.htmlの中身]');
$templateCache.put('e.html','[e.htmlの中身]');
$templateCache.put('f.html','[f.htmlの中身]');
$templateCache.put('g.html','[g.htmlの中身]');
}]);
```

```js:templates_gulp4.js
angular.module('your_app').run(['$templateCache', function($templateCache) {
$templateCache.put('/a.html','[a.htmlの中身]');
$templateCache.put('/b.html','[b.htmlの中身]');
$templateCache.put('/c.html','[c.htmlの中身]');
$templateCache.put('/d.html','[d.htmlの中身]');
$templateCache.put('/e.html','[e.htmlの中身]');
$templateCache.put('/f.html','[f.htmlの中身]');
$templateCache.put('/g.html','[g.htmlの中身]');
}]);
```

お気づきでしょうか。**生成されたjsになんか無駄にスラッシュが追加されています。**
これによりhtmlファイルが指し示すパスが変わってしまい、正しく表示されなかったというわけです。

#### 対策

先述のissueを確認する限り、解消方法は以下3点のいずれか。

* gulpのバージョンを3系に下げる
* gulp-angular-templatecacheのバージョンを2.2.4にする(2.2.5以上、2.2.3以下では不可)
* 別途gulp-replaceを導入して生成されたjsからスラッシュを消す

1点目はまた元に戻るだけなので論外。
2点目ですが、gulp-angular-templatecacheの対応時点での最新バージョンはv3.0.0であり、古いバージョンを明示して使うのもな...と思ったのでこれも却下。
結果として、やや力押し感はありますが3点目の方法で対処することにします。  

依存関係にgulp-replaceを追加して、

```diff_json:package.json
    "gulp": "^4.0.2",
+   "gulp-replace": "^1.1.3",
```

gulpfile側で生成されたtemplateのスラッシュを削除する処理を加えます。

```diff_javascript:gulpfile.js
  const templateCache = require('gulp-angular-templatecache');
+ const replace = require('gulp-replace');

// 中略

gulp.task('templates', function(done) {
    gulp.src('src/view/**/*.html')
        .pipe(templateCache({
            module: 'app',
            filename: 'templates.js'
        }))
+       .pipe(replace('put(\'/', 'put(\''))
        .pipe(gulp.dest('www/js'));
    done();
});
```

これでスラッシュが削除されたことでパスが正常化され、アプリはようやく期待通りの動作をすることができました。

### cordova/cordova-androidのアップデート

Node.js(とgulp)をアップデートするだけでこの苦労ぶり。先行きが不安ですが、気を取り直して次はcordovaと、cordovaでAndroidアプリを作成するためのcordova-androidをアップデートしていきましょう。

まずはpackage.jsonの記載をやっぱり安易に、対応時点での最新版に上げてみます。

```diff_json:package.json
-    "cordova": "9.0.0",
+    "cordova": "^10.0.0",
-    "cordova-android": "^8.0.0",
+    "cordova-android": "^10.1.1",
```

**やっぱり古い。**[^2]
[^2]: cordova v9.0.0のリリースは2019/03/20、cordova-android v8.0.0のリリースは2019/02/13。
そして依存関係のインストール...は成功。これはいけるか！？と思いビルドしてみますが

```shell-session
$ gulp && cordova run android --device -d

# 中略

Checking Java JDK and Android SDK versions
ANDROID_SDK_ROOT=/Users/your_name/Library/Android/SDK (recommended setting)
ANDROID_HOME=/Users/your_name/Library/Android/SDK (DEPRECATED)
Using Android SDK: /Users/your_name/Library/Android/SDK
Reading build config file: /Users/your_name/workspace/your_app/build.json
Reading the keystore from: /Users/your_name/workspace/your_app/debug.keystore
Subproject Path: CordovaLib
Subproject Path: app
Gradle Distribution URL: https://services.gradle.org/distributions/gradle-7.1.1-all.zip

FAILURE: Build completed with 2 failures.

1: Task failed with an exception.
-----------
* Where:
Script '/Users/your_name/workspace/your_app/platforms/android/cordova-plugin-browsertab/ic-BrowserTab.gradle' line: 3

* What went wrong:
A problem occurred evaluating script.
> Could not get unknown property 'cdvMinSdkVersion' for project ':app' of type org.gradle.api.Project.

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.
==============================================================================

2: Task failed with an exception.
-----------
* What went wrong:
A problem occurred configuring project ':app'.
> com.android.builder.errors.EvalIssueException: compileSdkVersion is not specified. Please add it to build.gradle

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.
==============================================================================

* Get more help at https://help.gradle.org
```

**はい。**

#### 何が起こったのか？(1)

cordova-androidにはcdvMinSdkVersionという、デフォルトでの最小SDKバージョンを指定する変数があります。いや**ありました。**
cordova-androidのv10.0からこの辺りの変数の指定方法に大幅な変更が入ったらしく、明示的に指定しない限りcdvMinSdkVersionは定義されなくなってしまったようです。

https://github.com/apache/cordova-android/pull/1212

で、それの何が問題なのかというと、[ある外部プラグイン](https://github.com/google/cordova-plugin-browsertab)に含まれていた以下のコード。

```gradle:BrowserTab.gradle
def minSdkVersion = 16

if(cdvMinSdkVersion == null) {
    ext.cdvMinSdkVersion = minSdkVersion;
} else if (cdvMinSdkVersion.toInteger() < minSdkVersion) {
    ext.cdvMinSdkVersion = minSdkVersion;
}
```

内容としてはcdvMinSdkVersionが16未満なら16に書き換えるというものですが、まあ先述の通りcdvMinSdkVersionがデフォルトで定義されなくなったためエラーを吐いているというわけです。

#### 対策(1)

コードを見て思いました。
**...これ要らなくね？**

[cordova-androidのv10.1.1ではデフォルトでの最小SDKバージョンが22に指定されており](https://github.com/apache/cordova-android/blob/rel/10.1.1/framework/cdv-gradle-config-defaults.json#L2)、また現在のプロダクトでは明示的に26を指定しているので、そもそも16未満になることがありません。
そうなるとこのコード自体無意味なものになるため、こちらは削除してしまいました。

というわけで削除して再ビルド。

```shell-session
$ gulp && cordova run android --device -d

# 中略

> Task :app:processDebugGoogleServices FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Some problems were found with the configuration of task ':app:processDebugGoogleServices' (type 'GoogleServicesTask').
  - In plugin 'com.google.gms.googleservices.GoogleServicesPlugin' type 'com.google.gms.googleservices.GoogleServicesTask' field 'intermediateDir' without corresponding getter has been annotated with @OutputDirectory.
    
    Reason: Annotations on fields are only used if there's a corresponding getter for the field.
    
    Possible solutions:
      1. Add a getter for field 'intermediateDir'.
      2. Remove the annotations on 'intermediateDir'.
    
    Please refer to https://docs.gradle.org/7.1.1/userguide/validation_problems.html#ignored_annotations_on_field for more details about this problem.
  - In plugin 'com.google.gms.googleservices.GoogleServicesPlugin' type 'com.google.gms.googleservices.GoogleServicesTask' field 'packageNameXOR1' without corresponding getter has been annotated with @Input.
    
    Reason: Annotations on fields are only used if there's a corresponding getter for the field.
    
    Possible solutions:
      1. Add a getter for field 'packageNameXOR1'.
      2. Remove the annotations on 'packageNameXOR1'.
    
    Please refer to https://docs.gradle.org/7.1.1/userguide/validation_problems.html#ignored_annotations_on_field for more details about this problem.
  - In plugin 'com.google.gms.googleservices.GoogleServicesPlugin' type 'com.google.gms.googleservices.GoogleServicesTask' field 'packageNameXOR2' without corresponding getter has been annotated with @Input.
    
    Reason: Annotations on fields are only used if there's a corresponding getter for the field.
    
    Possible solutions:
      1. Add a getter for field 'packageNameXOR2'.
      2. Remove the annotations on 'packageNameXOR2'.
    
    Please refer to https://docs.gradle.org/7.1.1/userguide/validation_problems.html#ignored_annotations_on_field for more details about this problem.
  - In plugin 'com.google.gms.googleservices.GoogleServicesPlugin' type 'com.google.gms.googleservices.GoogleServicesTask' field 'quickstartFile' without corresponding getter has been annotated with @InputFile, @Optional.
    
    Reason: Annotations on fields are only used if there's a corresponding getter for the field.
    
    Possible solutions:
      1. Add a getter for field 'quickstartFile'.
      2. Remove the annotations on 'quickstartFile'.
    
    Please refer to https://docs.gradle.org/7.1.1/userguide/validation_problems.html#ignored_annotations_on_field for more details about this problem.
  - In plugin 'com.google.gms.googleservices.GoogleServicesPlugin' type 'com.google.gms.googleservices.GoogleServicesTask' field 'searchedLocation' without corresponding getter has been annotated with @Input.
    
    Reason: Annotations on fields are only used if there's a corresponding getter for the field.
    
    Possible solutions:
      1. Add a getter for field 'searchedLocation'.
      2. Remove the annotations on 'searchedLocation'.
    
    Please refer to https://docs.gradle.org/7.1.1/userguide/validation_problems.html#ignored_annotations_on_field for more details about this problem.

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

* Get more help at https://help.gradle.org

Deprecated Gradle features were used in this build, making it incompatible with Gradle 8.0.

You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.

See https://docs.gradle.org/7.1.1/userguide/command_line_interface.html#sec:command_line_warnings

BUILD FAILED in 42s
```

まあ、そう簡単に行くはずもないですね...

#### 何が起こったのか？(2)

最近のバージョンのcordova-androidでは、それまでプラグインを入れなければ使用できなかったいくつかの機能がプラグインを入れなくても使用できるようになっています。
というよりむしろ、一部のプラグインは逆に入れたままにしているとビルド時にエラーを吐いてしまいます。

* cordova-android v9.0から使用可能
  * [cordova-support-google-services](https://github.com/chemerisuk/cordova-support-google-services)
  * [cordova-plugin-androidx](https://github.com/dpa99c/cordova-plugin-androidx)
* cordova-android v10.0から使用可能
  * [cordova-plugin-whitelist](https://github.com/apache/cordova-plugin-whitelist)

#### 対策(2)

上記に記載した通り、pluginが不要になりcordova-androidの標準機能として使用できるようになったため、それらのプラグインは削除しておきます。
標準機能としての使用方法は公式のリリースノートに対処法が記載されていました(当然ですが)。

https://cordova.apache.org/announcements/2020/06/29/cordova-android-9.0.0.html

https://cordova.apache.org/announcements/2021/07/20/cordova-android-10.0.0.html

これに従ってconfig.xmlに設定を追加していきます。

```diff_xml:config.xml
    <platform name="android">
        <!-- 中略 -->
        <preference name="android-targetSdkVersion" value="30" />
        <preference name="android-minSdkVersion" value="26"/>
+       <preference name="AndroidXEnabled" value="true" />
    </platform>
+   <config-file target="config.xml" parent="/*">
+       <preference name="GradlePluginGoogleServicesEnabled" value="true" />
+       <preference name="GradlePluginGoogleServicesVersion" value="4.3.10" />
+   </config-file>
```

というわけでこれらの設定も終わらせると、無事ビルド完了。実機デバッグも起動できて今度こそ...!
![スクリーンショット 2021-12-10 14.39.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308803/5a0908ec-b836-3488-b43d-d81b6c6b698c.png)
はい。まだ続きます。~~cordova-android「まだ俺のエラーフェイズは終了してないぜ」~~

#### 何が起こったのか？(3)

実はこれもcordova-android v10.0からの仕様で、html等の内部コンテンツには`file://`スキームではなく`https://localhost`でアクセスするように変更がされていました。
で、このプロダクトはサーバ側とデータ通信を行う必要があるのですが、なんと`https://`スキームでアクセスしているがゆえにそのデータ通信が別ドメインへのアクセス(=CORS policyに引っかかっている)とみなされてエラーになってしまっていました...

CORSについてはこちらを参照。

https://qiita.com/att55/items/2154a8aad8bf1409db2b

#### 対策(3)

上記の記事にあるようにAccess-Control-Allow-Originヘッダをサーバからのレスポンスに加えてしまえば解決なのですが、流石にセキュリティを考慮するとそんなことはできません。
加えて、このプロダクトはユーザの設定によってアクセス先のドメインまで変わるため、内部コンテンツのドメインを明示的に指定するわけにもいきません。

結果、あくまで一時的な解決策でしかないのですが、
今まで通り`file://`スキームでコンテンツにアクセスさせるように設定を変更しておきました。

```diff_xml:config.xml
    <platform name="android">
        <!-- 中略 -->
        <preference name="android-targetSdkVersion" value="30" />
        <preference name="android-minSdkVersion" value="26"/>
        <preference name="AndroidXEnabled" value="true" />
+       <preference name="AndroidInsecureFileModeEnabled" value="true" />
    </platform>
```

これでcordova関連のアップデートも完了し、無事に動作するようになりました。

### Firebase Crashlyticsのアップデート

さてここまで来ればもう一息です。
package.jsonに記載している依存ライブラリを確認し、上げられるものを片っ端から上げていきます。

Node.jsやcordova-androidほど解決に苦労したものはほとんどありませんでしたが唯一、Firebase Crashlyticsのアップデートで事故ったのでご報告。

このプロダクトの場合、Firebase Crashlyticsは自作でノーコードのプラグインを作成し、その中にbuild.gradleを配置することで依存ライブラリとして仕込んでいます。
問題はここのバージョンを上げた時に起こりました。

```diff_gradle:crashlytics_build.gradle
buildscript {
    repositories {
        google()
        jcenter()
    }

    dependencies {
-       classpath 'com.android.tools.build:gradle:3.4.2'
+       classpath 'com.android.tools.build:gradle:7.0.3'
-       classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1'
+       classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'
    }
}

apply plugin: com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsPlugin

dependencies {
-   implementation 'com.google.firebase:firebase-crashlytics:1７.3.+'
+   implementation 'com.google.firebase:firebase-crashlytics:18.2.3'
}
```

いつも通りビルドしようとすると...

```shell-session
> Task :app:uploadCrashlyticsMappingFileRelease FAILED

FAILURE: Build failed with an exception.

* What went wrong:
A problem was found with the configuration of task ':app:uploadCrashlyticsMappingFileRelease' (type 'UploadMappingFileTask').
  - In plugin 'com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsPlugin' type 'com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask' property 'googleServicesResourceRoot' doesn't have a configured value.
    
    Reason: This property isn't marked as optional and no value has been configured.
    
    Possible solutions:
      1. Assign a value to 'googleServicesResourceRoot'.
      2. Mark property 'googleServicesResourceRoot' as optional.
    
    Please refer to https://docs.gradle.org/7.1.1/userguide/validation_problems.html#value_not_set for more details about this problem.

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

* Get more help at https://help.gradle.org

Deprecated Gradle features were used in this build, making it incompatible with Gradle 8.0.

You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.

See https://docs.gradle.org/7.1.1/userguide/command_line_interface.html#sec:command_line_warnings

BUILD FAILED in 24s
```

やっぱりエラーが。

#### 何が起こったのか？

エラー内容自体は同じような投稿がありました。  

https://stackoverflow.com/questions/67865303/type-uploadmappingfiletask-property-googleservicesresourceroot-doesnt-have

問題は(自動生成された)gradleファイルの以下のコード。

```gradle:build.gradle
apply plugin: 'com.google.firebase.crashlytics'
apply plugin: 'com.google.gms.google-services'
```

なんとこれ、firebase-crashlytics-gradleがv2.7.0以降の場合、**crashlyticsよりgoogle-servicesの方を先にapplyしないとエラーになる**みたいです。
gradleファイルで記載順まで指定されるとは...

#### 対策

これに関しては単純にbuild.gradleの記載順を入れ替えればいい、というのがベストアンサーでしたが、cordovaアプリはplatform(=build.gradle)が自動生成されてしまうためそれを自動的に制御する方法が思いつかず...
泣く泣く、firebase-crashlytics-gradle自体のバージョンをv2.6.1に下げることで対応しました。

## 最後に

フレームワークや依存ライブラリ、pluginを一通り上げ終わった後で、**全機能の疎通テストを行いました。**
幸いそこまで大きなプロダクトではなかったため時間はかかりませんでしたが、大半のバージョンを上げたことで目に見えない箇所に不具合が発生している可能性があるため、ここは特に慎重に進めました。
**そして案の定不具合が4件見つかりました。**内容については伏せますが、これを修正して完了です。

いやあ長かった。
皆さんもこうならないようこまめにアップデートはしていきましょう！

そして明日は@r_numotoさんの記事です。お楽しみに！