Monacaのアプリ名多言語化について

アプリ内のコンテンツについては、例えばOnsenUI+Vue.jsで作っている場合は[vue-i18n](https://github.com/kazupon/vue-i18n)などのライブラリを使って簡単に多言語化できますが、アプリ名の多言語化でハマったので対応した結果をまとめました。

## 多言語化したい箇所
<img width="200" alt="多言語化したい箇所" src="https://qiita-image-store.s3.amazonaws.com/0/51815/cd66b1da-560e-b564-9e54-53ddce466bda.png">

## ネイティブアプリの場合
ネイティブアプリは以下の様にしてアプリ名の多言語化を行います。

### Android
1. resにvalues-enなど、言語ごとのディレクトリを作成
1. strings.xmlを作成し、「app_name」にアプリ名を記述

### iOS
1. XcodeからInfoPlist.stringを作成し、Localizationをクリックして各言語をチェックする
1. InfoPlist.stringの「CFBundleDisplayName」にアプリ名を記述

じゃあ、strings.xmlとInfoPlist.stringをコピーすればよいのでは？と考えました。

## CordovaのHook機能を使ってみる
Hook機能とはCordovaの各種操作時(プラグイン追加時、プリペア時など)に処理を挟み込むことができる機能です。
この機能を使用して、after_prepare（プリペア後）に、strings.xmlとInfoPlist.stringをコピーするHookスクリプトを作成してみました。

## Hookスクリプト
汎用的に他のプロジェクトでも使いたいので、プラグインとして作成します。

※直接プロジェクトにHookスクリプトを配置することもできます。その場合は、ルート直下のhookディレクトリにHookスクリプトを配置することが一般的ですが、Monacaはwww配下以外のファイルをビルド時に参照できないので、www以下に配置する必要があります。

### ディレクトリ構成
<img width="915" alt="ディレクトリ構成" src="https://qiita-image-store.s3.amazonaws.com/0/51815/e324879d-58fc-d41d-5c55-83757d0e3459.png">

### plugin.xml
```xml:plugin.xml
<?xml version='1.0' encoding='utf-8'?>
<plugin id="cordova-plugin-i18n-app-name" version="0.0.1" xmlns="http://apache.org/cordova/ns/plugins/1.0" xmlns:android="http://schemas.android.com/apk/res/android">
  <name>cordova-plugin-i18n-app-name</name>

  <platform name="android">
    <hook src="scripts/copy_android_strings.js" type="after_prepare" />
  </platform>

  <platform name="ios">
    <hook src="scripts/copy_ios_strings.js" type="after_prepare" />
  </platform>
</plugin>
```

### Hookスクリプト(Android)
```js:copy_android_strings.js
var fs = require('fs-extra');

function fileExists(file) {
  try  {
    return fs.statSync(file).isFile();
  } catch (error) {
    return false;
  }
}

module.exports = function(context) {
  var q = context.requireCordovaModule('q');
  var glob = context.requireCordovaModule('glob');

  var deferred = q.defer();

  // globで言語ごとに走査
  glob('www/locales/android/values-*', function (error, files) {
    if (error) {
      deferred.reject(error);
    } else {
      files.forEach(function (file) {
        var stringFile = file + '/strings.xml';
        if (fileExists(stringFile)) {
          var lang = file.match(/\/values-(.+)$/)[1];
          var distDir = 'platforms/android/res/values-' + lang;

          fs.ensureDirSync(distDir);
          // strings.xmlをコピー
          fs.copySync(stringFile, distDir + '/strings.xml', { replace: true });

          console.log('copyFrom: ' + stringFile);
          console.log('copyTo: ' + distDir + '/strings.xml');
        }
      });
      deferred.resolve();
    }
  });

  return deferred.promise;
};
```

### コピーするリソースファイル
先述した通り、Monacaはビルド時にwww配下以外のファイルを参照できないので、コピーするリソースファイル(strings.xml, InfoPlist.strings)はwwwに配置します。
ディレクトリ構成はPhoneGap Buildが似た様な機能を標準で用意していたので、同じ様なディレクトリ構成にしました。

<img width="905" alt="コピーするリソースファイル" src="https://qiita-image-store.s3.amazonaws.com/0/51815/93141bd3-d33d-09f0-2bf0-db1ab53ef58b.png">

## project.pbxprojの罠
Androidはstrings.xmlをコピーするだけでうまくいったのですが、iOSはコピーするだけだとうまくいきませんでした。
iOSはXcodeに認識させるために、リソースファイル(InfoPlist.string)とそのLocalization設定をproject.pbxproj(Xcode使ってると、よくコンフリクトする謎フォーマットのアレです)に定義する必要があるようです。

### project.pbxprojの構成
<img width="934" alt="project.pbxprojの構成" src="https://qiita-image-store.s3.amazonaws.com/0/51815/bd64009c-4663-7fc7-612b-138c690cadb4.png">

### node-xcode
project.pbxprojをパース・編集できるnode.jsのライブラリです。
このライブラリを使って、 PBXFireReferenceとPBXVariantGroupを設定します。

### node-xcodeのインストール
CustomConfigプラグインを使用していればインストール不要と思っていたのですが、Monaca標準のCustomConfigプラグインのバージョン(2.0.3)だとnode-xcodeのバージョンが0.8.xと古く、PBXVariantGroupの編集ができませんでした。(PBXVariantGroupの編集は0.9.x以降対応)

CustomConfigプラグインを最新(4.0.2)にしてみるも、なぜかビルドでコケるので、```npm install xcode```を今回作成したプラグインのルート直下で実行して最新のnode-xcodeをインストールし、node_modules以下をまるごとアップロードしました。

### Hookスクリプト(iOS)
以下のHookスクリプトを実行することで、iOSもアプリ名の多言語化ができました！

```js:copy_ios_strings.js
var fs = require('fs-extra');
var xcode = require('xcode');

function fileExists(file) {
  try  {
    return fs.statSync(file).isFile();
  } catch (error) {
    return false;
  }
}

function values(object) {
  return Object.keys(object).map(function (k) {
    return object[k];
  });
}

// from: https://github.com/kelvinhokk/cordova-plugin-localization-strings
function writeLocalizationFieldsToXcodeProj(filePaths, groupname, proj) {
  var fileRefSection = proj.pbxFileReferenceSection();
  var fileRefValues = values(fileRefSection);

  if (filePaths.length > 0) {

    // var groupKey;
    var groupKey = proj.findPBXVariantGroupKey({name: groupname});
    if (!groupKey) {
      // findPBXVariantGroupKey with name InfoPlist.strings not found.  creating new group
      var localizableStringVarGroup = proj.addLocalizationVariantGroup(groupname);
      groupKey = localizableStringVarGroup.fileRef;
    }

    filePaths.forEach(function (path) {
      var results = fileRefValues.filter(function (v) {
        return v.path === '"' + path + '"';
      });
      if (Array.isArray(results) && results.length === 0) {
        //not found in pbxFileReference yet
        proj.addResourceFile('Resources/' + path, {variantGroup: true}, groupKey);
      }
    });
  }
}

module.exports = function(context) {
  var q = context.requireCordovaModule('q');
  var glob = context.requireCordovaModule('glob');

  var configXml = fs.readFileSync('config.xml').toString();
  var appName = configXml.match(/<name>(.*)<\/name>/i)[1];
  var projDir = 'platforms/ios/' + appName;
  var pbxProjFile = projDir + '.xcodeproj/project.pbxproj';

  var deferred = q.defer();

  // globで言語ごとに走査
  glob('www/locales/ios/*.lproj', function (error, files) {
    if (error) {
      deferred.reject(error);
    } else {
      var stringFiles = [];

      files.forEach(function (file) {
        var stringFile = file + '/InfoPlist.strings';
        if (fileExists(stringFile)) {
          var lang = file.match(/\/([^/]+)\.lproj$/)[1];
          var distDir = projDir + '/Resources/' + lang + '.lproj';

          fs.ensureDirSync(distDir);
          // InfoPlist.stringsをコピー
          fs.copySync(stringFile, distDir + '/InfoPlist.strings', { replace: true });

          console.log('copyFrom: ' + stringFile);
          console.log('copyTo: ' + distDir + '/InfoPlist.strings');

          stringFiles.push(lang + '.lproj/InfoPlist.strings');
        }
      });

      var proj = xcode.project(pbxProjFile);

      // project.pbxprojをパース
      proj.parse(function (err) {
        if (err) {
          deferred.reject(err);
        } else {
          // project.pbxprojにPBXFireReferenceとPBXVariantGroupを設定
          writeLocalizationFieldsToXcodeProj(stringFiles, 'InfoPlist.strings', proj);
          fs.writeFileSync(pbxProjFile, proj.writeSync());

          console.log('ok!!');
          deferred.resolve();
        }
      });

      deferred.resolve();
    }
  });

  return deferred.promise;
};
```

## 所感
思ったより面倒でしたが、Hookスクリプトの作成方法の良い勉強になりました。
[iBeaconのプラグイン](https://github.com/petermetz/cordova-plugin-ibeacon)などを使っていると、位置情報の権限をユーザに許可してもらう際のメッセージが英語固定になっていることがありますが、これも同じ方法で多言語化できる気がするので試してみたいと思います。

## 参考
[cordova-plugin-localization-strings]
(https://github.com/kelvinhokk/cordova-plugin-localizationstrings)
node-xcodeの使い方の参考にさせて頂きました。（一部コードを流用しています）

[.xcodeproj/project.pbxproj を解読する](https://qiita.com/yokomotod/items/02e395e99bb891d27f67)
project.pbxprojの仕様の参考にさせて頂きました。

## ソースコード
GitHubで公開してます。
https://github.com/kishino/cordova-plugin-i18n-app-name