もう迷わない！ Cordova/Monacaプラグインのカスタマイズ --- InAppBrowserプラグインを改修してみる

## 概要

Monaca/Cordovaでは、さまざまなプラグインを利用することが出来ますが、アプリを開発している時に、標準のプラグインでは足りない場合が出て来ます。そんな時は、プラグインを改修して、必要な機能をつけたしていくことが出来るのですが、あまりまとまった情報がなくて、苦労することが多いです。
そこで、プラグイン改修のサンプルとして、InAppBrowserに機能追加する方法を紹介します。

なお、この記事の内容を試すにはローカルでCordovaを実行する環境が必要です。また、ネイティブコードを修正しますので、iOSであればXcode、AndroidであればAndroidStudioが必要になります。

(なお、非常に大変ではありますが、MonacaのProプランなどを使って、cordova-plugin-inappbrowserプラグインをzipファイルで組み込み、Monaca IDE上でplugin内のコードを修正することで試すことも可能です）

## どんな改修か？

丁度先日、Teratailさんで、「特定のURLだけ今のまま移動しない」処理をしたいというのがあったので、これをテーマに考えてみます。([JavaScript - Monaca InAppBrowserで特定URLの場合「今のまま移動しない」ようにしたい(104255)｜teratail](https://teratail.com/questions/104255))

要件としては、あらかじめ正規表現でURLパターンを設定しておいて、その正規表現に合致したときだけ、リンク先へページ遷移しないような機能とします。また、この機能が発動したときに、unloadイベントが発火して、JS側で取得出来るようにします。

なお、後で紹介していますが、本修正をしたものを https://github.com/knight9999/cordova-plugin-inappbrowser/tree/unload_list においてあるので、すぐに使ってみたい方はそちらを試してみてください。

## 具体例

この改修により、次のようなテストケースを動作させることができるようになります。

1. ページ遷移を禁止するURLパターンとして、「`https:\/\/github\.com\/apache\/cordova-plugin-inappbrowser\/blob\/master\/README\.md`」を設定する。 (README.mdが読めない)
2. InAppBrowserで、https://github.com/apache/cordova-plugin-inappbrowser を開く
3. README.mdへのリンクをクリックしても、ページ遷移出来ない
4. unloadのイベントが発火する


プログラム的には次のようになるでしょう。

```js
  document.getElementById('btn').addEventListener('click', function() {
    var ref = cordova.InAppBrowser.open('https://github.com/apache/cordova-plugin-inappbrowser', '_blank', 'location=yes', null, ['https:\/\/github\.com\/apache\/cordova-plugin-inappbrowser\/blob\/master\/README\.md']);
    ref.addEventListener('unload', function(json) { alert( json.url ); } );
  },false);
```

`cordova.InAppBrowser.open`の第五パラメータに、正規表現のリストを入れられるようにします。この正規表現にマッチしたら、そのページを読み込まず、その代わりに、unloadイベントが発火するというわけです。


## 作業用アプリの作成

プラグインはプラグインだけでは動作しないので、作業用のアプリを作ってそこにプラグインを組み込み、開発を行います。

まずは、githubからcordova-plugin-inappbrowserを取得します。適当なディレクトリで、次を実行してください。

```
$ git clone https://github.com/apache/cordova-plugin-inappbrowser.git
$ git checkout 1.6.1
```

cloneしたディレクトリを、今後は`[/path/to/cordova-plugin-inappbrowser]`とします。
バージョン 1.6.1をベースに、開発することにします。(バージョン 1.7.xは、現在のところ、iOSのUIWebViewで不具合があるため、この記事では1.6系をもとにします）

次に、このプラグインを使った作業用アプリを用意します。別のディレクトリを作成して、次を実行してください。

```
$ cordova create sample
$ cd sample
$ cordova platform add ios@4.4.0
$ cordova platform add android@6.2.3
```

ここで、cordova cliは6.5.0とします。これは現在のMonacaの最新版と同じです。
また、iOSのプラットフォームバージョンを4.4.0、Androidのプラットフォームバージョンを6.2.3としているのも、現在のMonacaの最新版に合わせるためです。

そして次に、先ほどcloneしたcordova-plugin-inappbrowserを組み込みます。

```
$ cordova plugin add [/path/to/cordova-plugin-inappbrowser]
```

これで、開発環境が出来ました。以後は、この作業用アプリディレクトリで作業を行います。

## 動作確認

まずは、動作確認をしてみましょう。iOS、Androidどちらでも良いのですが、ここではiOSで試してみることにします。

www/index.htmlファイルを開いて、次のようにbuttonタグを追加します。

修正前

```html
        <div class="app">
            <h1>Apache Cordova</h1>
            <div id="deviceready" class="blink">
                <p class="event listening">Connecting to Device</p>
                <p class="event received">Device is Ready</p>
            </div>
        </div>
```
修正後

```html
        <div class="app">
            <h1>Apache Cordova</h1>
            <div id="deviceready" class="blink">
                <p class="event listening">Connecting to Device</p>
                <p class="event received">Device is Ready</p>
            </div>
            <button id="btn">Go Btn</button>
        </div>
```

そして、js/index.jsに、次のようにbtnをタップしたときの動作を登録します。

修正前

```js
    onDeviceReady: function() {
        this.receivedEvent('deviceready');
    },
```
修正後

```js
    onDeviceReady: function() {
        this.receivedEvent('deviceready');
        document.getElementById('btn').addEventListener('click', function() {
          var ref = cordova.InAppBrowser.open('https://github.com/apache/cordova-plugin-inappbrowser', '_blank', 'location=yes');
        },false);
    },
```
これはまだ普通のInAppBrowserなので、openメソッドの第5引数は設定していません。

```
$ cordova prepare ios
```
を実行して、その後、Xcodeでプロジェクトを開き、実機で動作確認してみましょう。

```
$ open platforms/ios/HelloCordova.xcworkspace
```

HelloCorodvaの画面が出たら、その下のbtnボタンを押して、githubのcordova-plugin-inappbrowserのページが表示されましたでしょうか？

## JavaScript部分の改修

動作確認ができたら、InAppBrowserプラグインの改修として、まずは、JavaScript部分を改修します。
JavaScript部分は、AndroidとiOSで共通なのですが、まずはiOSに焦点を当てて修正していきます。というのも、プラグインの改修では何度も動作テストを繰り返すため、iOSで使っているものに手を入れて確認し、それが完了したら、プラグインの大元にフィードバックするというやり方が効率が良いからです。（自分はそう思いますが、人によっては違うかも知れません）

改修する場所は、普通に考えると/plugins/cordova-plugins-inappbrowser/www/inappbrowser.jsなのですが、実はこれはプラグインをaddしたときに組み込まれたものが置いてあるだけなので、これは修正しません。

また、実際にXcodeで実行されているのは、platforms/ios/www/plugins/cordova-plugins-inappbrowser/www/inappbrowser.jsなのですが、ここを書き換えても、cordova prepareをするたびに元に戻ってしまうので、これも修正しません。

プラグイン開発時に修正するjavascriptは、platforms/ios/platform_www/plugins/cordova-plugin-inappbrowser/www/inappbrowser.jsになります。ここを変更すると、cordova prepareするたびに、Xcodeで実行されるplatforms/ios/www/plugins/cordova-plugins-inappbrowser/www/inappbrowser.jsにコピーされます。

ここを次のように変更してみましょう。

変更前：

```js
    function InAppBrowser() {
       this.channels = {
            'loadstart': channel.create('loadstart'),
            'loadstop' : channel.create('loadstop'),
            'loaderror' : channel.create('loaderror'),
            'exit' : channel.create('exit')
       };
    }
```
変更後：

```js
    function InAppBrowser() {
       this.channels = {
            'loadstart': channel.create('loadstart'),
            'loadstop' : channel.create('loadstop'),
            'loaderror' : channel.create('loaderror'),
            'unload' : channel.create('unload'),
            'exit' : channel.create('exit')
       };
    }
```

チャンネルにunloadを一つ追加しました。

そして、さらに次のように変更します。

修正前

```js
    module.exports = function(strUrl, strWindowName, strWindowFeatures, callbacks) {
        // Don't catch calls that write to existing frames (e.g. named iframes).
        if (window.frames && window.frames[strWindowName]) {
            var origOpenFunc = modulemapper.getOriginalSymbol(window, 'open');
            return origOpenFunc.apply(window, arguments);
        }

        strUrl = urlutil.makeAbsolute(strUrl);
        var iab = new InAppBrowser();

        callbacks = callbacks || {};
        for (var callbackName in callbacks) {
            iab.addEventListener(callbackName, callbacks[callbackName]);
        }

        var cb = function(eventname) {
           iab._eventHandler(eventname);
        };

        strWindowFeatures = strWindowFeatures || "";

        exec(cb, cb, "InAppBrowser", "open", [strUrl, strWindowName, strWindowFeatures]);
        return iab;
    };
```

修正後

```js
    module.exports = function(strUrl, strWindowName, strWindowFeatures, callbacks, ignoreList) {
        // Don't catch calls that write to existing frames (e.g. named iframes).
        if (window.frames && window.frames[strWindowName]) {
            var origOpenFunc = modulemapper.getOriginalSymbol(window, 'open');
            return origOpenFunc.apply(window, arguments);
        }

        strUrl = urlutil.makeAbsolute(strUrl);
        var iab = new InAppBrowser();

        callbacks = callbacks || {};
        for (var callbackName in callbacks) {
            iab.addEventListener(callbackName, callbacks[callbackName]);
        }

        ignoreList = ignoreList || [];

        var cb = function(eventname) {
           iab._eventHandler(eventname);
        };

        strWindowFeatures = strWindowFeatures || "";

        exec(cb, cb, "InAppBrowser", "open", [strUrl, strWindowName, strWindowFeatures, ignoreList]);
        return iab;
    };
```
functionの第五パラメータとしてignoreListを受け取るようにしました。そして、それが空の場合は空の配列として、exec時の第五引数の最後に追加しています。

これで、JavaScript部分の改修は終わりです。

## Objective-Cの修正

次に、Objective-Cの修正をしてみましょう。対象ファイルはplatforms/ios/HelloCordova/Plugins/cordova-plugin-inappbrowser/CDVInAppBrowser.mファイルです。テキストエディタではなく、アシストの効くXcodeで開いて、修正しましょう。

まず、インスタンス変数として、_unloadListを用意します。この変数は、JavaScriptで送られて来た正規表現のリストを保持する変数です。

修正前

```objc
 @interface CDVInAppBrowser () {
     NSInteger _previousStatusBarStyle;
 }
```

修正後

```objc
 @interface CDVInAppBrowser () {
     NSInteger _previousStatusBarStyle;
     NSArray<NSString *> *_unloadList;
 }
 ```

次に、openメソッドで、JavaScriptから渡された正規表現のリストを_unloadListに保持します。

修正前

```objc
- (void)open:(CDVInvokedUrlCommand*)command
{
    CDVPluginResult* pluginResult;

    NSString* url = [command argumentAtIndex:0];
    NSString* target = [command argumentAtIndex:1 withDefault:kInAppBrowserTargetSelf];
    NSString* options = [command argumentAtIndex:2 withDefault:@"" andClass:[NSString class]];
```

修正後

```objc
- (void)open:(CDVInvokedUrlCommand*)command
{
    CDVPluginResult* pluginResult;

    NSString* url = [command argumentAtIndex:0];
    NSString* target = [command argumentAtIndex:1 withDefault:kInAppBrowserTargetSelf];
    NSString* options = [command argumentAtIndex:2 withDefault:@"" andClass:[NSString class]];
    _unloadList = [command argumentAtIndex:3 withDefault:@[] andClass:[NSArray<NSString *> class]];
```

次に、ページ遷移時に指定したURLが_unloadListにマッチするかどうかを判定し、マッチしたらページ遷移をやめて、unloadイベントを発火するようにしましょう。これは、webView:shouldStartLoadWithRequest:navigationType:というメソッドを使うことで実装できます。このメソッドがYESを返すとページ遷移出来て、NOを返すとページ遷移しなくなります。

このメソッドの最後を次のように修正します。

修正前

```objc
return YES;
```

修正後

```objc
    __block BOOL unloadFlag = NO;
    NSString *urlStr = [url absoluteString];
    [_unloadList enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        
        NSError *error = nil;
        NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:obj
                                                          options:NSRegularExpressionCaseInsensitive
                                                            error:&error];
        if (! error) {
            NSTextCheckingResult *match = [regex firstMatchInString:urlStr
                                                            options:0
                                                          range:NSMakeRange(0, urlStr.length)];
            if (match) {
                unloadFlag = YES;
            }
        }
    }];
    if (unloadFlag) {
        CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK
                                                      messageAsDictionary:@{@"type":@"unload", @"url":urlStr}];
        [pluginResult setKeepCallback:[NSNumber numberWithBool:YES]];
        
        [self.commandDelegate sendPluginResult:pluginResult callbackId:self.callbackId];
        return NO;
    }

    return YES;
```

これで完成です。 `__block` 指定子がしてあるのは、この変数がブロック構文内から変更される（可能性がある）ためです。

## 動作確認

アプリ側も新しいInAppBrowserを使うように変更してみましょう

www/index.jsを次のように修正します。

```js
    onDeviceReady: function() {
        this.receivedEvent('deviceready');
        document.getElementById('btn').addEventListener('click', function() {
          var ref = cordova.InAppBrowser.open('https://github.com/apache/cordova-plugin-inappbrowser', '_blank', 'location=yes', null, ['https:\/\/github\.com\/apache\/cordova-plugin-inappbrowser\/blob\/master\/README\.md']);
          ref.addEventListener('unload', function(json) { alert( json.url ); } );
        },false);
    },
```

これで、openメソッドの第五引数で指定しているREADME.mdは開けなくなり、その時は、unloadイベントが発火するようになりました。

## プラグインへのフィードバック

今回作業用アプリ内で修正したファイルは、次の二つです。

platforms/ios/platform_www/plugins/cordova-plugin-inappbrowser/www/inappbrowser.js
platforms/ios/HelloCordova/Plugins/cordova-plugin-inappbrowser/CDVInAppBrowser.m

この二つの変更を、InAppBrowserプラグインに反映しなくてはいけません。InAppBroweserプラグインのディレクトリで、上記に対応するのは次になります。

www/inappbrowser.js
src/iOS/CDVInAppBrowser.m

単純に考えると、そっくり上書きすれば良いように思いますが、inappbrowser.jsについては上書きしてはダメです。

platforms/ios/platform_www/plugins/cordova-plugin-inappbrowser/www/inappbrowser.js
を開いてみると、

```js
cordova.define("cordova-plugin-inappbrowser.inappbrowser", function(require, exports, module) {
 // コードの中身
});
```

という形をしていると思いますが、この「コードの中身」だけをwww/inappbrowser.jsとして上書きしてください。
つまり、開発用アプリのplatforms/ios/platform_www/plugins/cordova-plugin-inappbrowser/www/inappbrowser.js
の最初の行と最後の行を外したものが、cordova-plugin-inappbrowserプラグインのwww/inappbrowser.jsになります。

CDVInAppBrowser.mの方は、そのまま上書きしてコピーしてOKです。

## ここまでのまとめ

以上で修正したものが、https://github.com/knight9999/cordova-plugin-inappbrowser/tree/unload_list になります。


## Androidへの対応

開発アプリの方は、すでに組み込まれている開発用のcordova-plugin-inappbrowserを外して、再度、回収したcordova-plugin-inappbrowserを組み込み直しましょう。

```
$ cordova plugin rm cordova-plugin-inappbrowser
$ cordova plugin add [/path/to/cordova-plugin-inappbrowser]
```

これで、修正したJavaScriptがAndroid側にも反映されました。`platforms/android/platform_www/plugins/cordova-plugin-inappbrowser/www/inappbrowser.js`を確認してみてください。

そして、一度prepareしておきます。

```
$ cordova prepare android
```

## Android Studioについて

最新版であるAndroid Studio 3.0系だと、Cordova-Android 6.2.3をうまくビルドできないと思います。
その場合は、https://services.gradle.org/distributions/ から、gradle-3.2-all.zipをダウンロードして展開して、Android Studioから使えるようにしてください。(Macであれば、/Application/Android Studio.app/Contents/gradleの下に配置することになります）

 `cordova compile`などでEACCESSのエラーが出たときは、`-d`コマンドでエラーの詳細を表示して対応してください。

```
$ cordova compile android -d
```

また、Android Studioで開いた時に、gradleをdowngradeするようにメッセージが出たら、downgradeしてください。

## Javaコードの修正

次に、Android Studioで`platforms/android`ディレクトリを開きます。Android Studioを立ち上げて、Open an existing Android Studio projectで、`platforms/android`ディレクトリを開いてください。

修正の対象ファイルは、`platforms/android/src/org/apache/cordova/inappbrowser/InAppBrowser.java`です。

まず、`JSONArray`を使いたいので、これをインポートしておきます。

修正後

```java
import org.json.JSONArray;
```

そして、InAppBrowserのクラス宣言の直後に、インスタンス変数としてunloadListを次のように定義します。

修正前

```java
    private boolean shouldPauseInAppBrowser = false;
    private boolean useWideViewPort = true;
```

修正後

```java
    private boolean shouldPauseInAppBrowser = false;
    private boolean useWideViewPort = true;
    private JSONArray unloadList;
```

そして、JavaScriptからopenメソッドで呼ばれた時に、第5引数で代入された配列をJSONArrayに格納するようにします。InAppBrowserクラスのexecuteメソッド内を修正します。

修正前

```java
            final String target = t;
            final HashMap<String, Boolean> features = parseFeature(args.optString(2));
```

修正後

```java
            final String target = t;
            final HashMap<String, Boolean> features = parseFeature(args.optString(2));
            unloadList = args.optJSONArray(3);
```

そして最後に、InAppBrowserClientクラス (InAppBrowser.javaファイル内にあります)の`shouldOverrideUrlLoading`メソッドを修正します。ここで、正規表現にマッチしたら、unloadイベントを返し、ページ遷移しないようにします。このメソッドの最後に判定処理を追加します。　

修正前

```java
            return false;
```

修正後

```java
            boolean unloadFlag = false;
            for (int i=0;i<unloadList.length();i++) {
                String regex = unloadList.optString(i);
                if (url.matches(regex)) {
                    unloadFlag = true;
                }
            }
            if (unloadFlag) {
                try {
                    JSONObject obj = new JSONObject();
                    obj.put("type", "unload");
                    obj.put("url", url);
                    sendUpdate(obj, true);
                } catch (JSONException ex) {
                    LOG.e(LOG_TAG, "URI passed in has caused a JSON error.");
                }
                return true;
            }

            return false;
```

iOSのときの`webView:shouldStartLoadWithRequest:navigationType:`と似ていますが、Androidの`shouldOverrideUrlLoading`では返す真偽値が逆になっているので注意してください。似たような機能を持つメソッドですが、iOSとAndroidには互換性はありません。それぞれのフレームワークが持つ別の機能です。

これで完成しました。

iOSのときと同様、これをプラグインに反映するためには、この開発用アプリの

```
platforms/android/src/org/apache/cordova/inappbrowser/InAppBrowser.java
```

をcordova-plugin-inappbrowserプラグインのディレクトリの

```
src/android/InAppBrowser.java
```

に上書きしてください。

## ここまでのまとめ

Androidの修正についても、https://github.com/knight9999/cordova-plugin-inappbrowser/tree/unload_list に反映してあります。また、プラグインのバージョンを1.6.1-unloadとしてあります。

## まとめ

InAppBrowserを題材として、Cordova/Monacaプラグインを改修する方法について紹介しました。

プラグイン開発・改修の作業は、JavaやObjecitve-Cといったネイティブコードの知識、CocoaフレームワークやAndroidフレームワークの知識が必要になるほか、CordovaLibの仕組みや、cordovaプラグインの設定ファイルの仕組み、フックスクリプトなどについての知識など、さまざまに要求されるものがあるため、なかなかハードルの高い作業です。
(フックスクリプトについては、https://qiita.com/KNaito/items/65587f5d51974e8b4adf に記事を書いたのでこちらも参考にしてください）

とはいえ、プラグインの開発を通して視野が広がり、アプリの機能をどのように分解するべきかといった考えが身につくと思います。また、プラグインは再利用できますし、今後のアプリ開発の資産ともなりますので、ぜひトライしてみてほしいです。