ExpoでAPKをビルド後にAndroidManifest.xmlをいじってみる

## やりたいこと
ExpoのデフォルトのAndroidManifest.xmlではapplicationの`android:allowBackup`が`true`で、つまり自動バックアップが有効になっています。

**[追記(2020/08/20)]**
*SDK38からallowBackupの設定はできるようになっています。*
https://blog.expo.io/expo-sdk-38-is-now-available-ab6cd30ca2ee#d757

訳あってこれを`false`にしたい。
しかしExpoのManaged WorkflowではiOSのinfoPlistのようにはAndroidManifest.xmlの詳細項目を直接設定することはできないようで、Feature requestsとしてもいくつか挙がっています。
https://expo.canny.io/feature-requests?search=allowbackup

そのため、ビルド後のAPKファイルを展開してAndroidManifest.xmlを編集、再ビルドという手順で無理矢理いじってみることにしました。Javaのソースコードはともかく、マニフェストファイルくらいは簡単に書き換えられるだろうということで。
あまり聞こえのいい感じはしませんが、とりあえずできるかどうか試してみたという感じです。

## まずは`allowBackup="true"`の挙動を確認

こんな感じで入力したテキストを保存するような画面を簡単に作ってみます。
バックアップはAsyncStorageで保存したデータなども含むはず。

```jsx:App.js
import React, { useState, useEffect } from 'react';
import { StyleSheet, View, AsyncStorage, Button, TextInput } from 'react-native';

export default function App() {
  const [text, setText] = useState('');

  useEffect(() => {
    AsyncStorage.getItem('text').then(setText);
  }, []);

  const saveText = () => {
    AsyncStorage.setItem('text', text);
  };

  return (
    <View style={styles.container}>
      <TextInput style={styles.input} value={text} onChangeText={setText} />
      <Button title="Save" onPress={saveText} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  input: {
    backgroundColor: 'lightgray',
    width: 200,
    height: 40,
    marginBottom: 16
  }
});
```

`app.json`にパッケージ名を設定したうえでAndroid向けにビルドし、Android端末で確認してみます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/9f0a0592-3a3f-ba2b-c528-029475d41c6e.png" width="320">
何かしら入力したあと「SAVE」ボタンを押し、AsyncStorageに保存させます。再度起動しても同じ文字列が表示されるようになるはずです。

この状態で、adbを使ってバックアップをしてみます。
（[この辺りのドキュメント](https://developer.android.com/guide/topics/data/testingbackup?hl=ja)や[記事](http://tiro105.hateblo.jp/entry/2015/10/23/204154)を参考にしています。）

```console
$ adb shell setprop log.tag.BackupXmlParserLogging VERBOSE
$ adb shell bmgr run
$ adb shell bmgr backupnow <パッケージ名>
```

バックアップができたら、先ほど入力した文字列を消して「SAVE」し、
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/131e7f66-4a49-e369-b597-6133b8c01f2e.png" width="320">
再度起動しても入力した文字列が消えているのを確認します。

ここで先ほどバックアップした状態を復元してみます。
バックアップを特定するためのトークンを確認するコマンドを叩きましょう。

```console
$ adb shell dumpsys backup
```

ずらっと出てきた中に、`Current: ~~`とあるのがバックアップトークンです。よほど時間が経っていない限りこれが先ほど行ったバックアップのものなので、

```console
$ adb shell bmgr restore <バックアップトークン> <パッケージ名>
```

こんな感じで叩けば復元が完了するはずです。

再度アプリを開いてみると、文字列が復活しました。
バックアップした時のAsyncStorageのデータが復元されているのがわかります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/45b6394b-4156-a03b-9f48-def6b7c410d1.png" width="320">

これで`allowBackup="true"`が正常に効いているのがわかりました。

## ビルド後のAPKをいじってみる

ビルド後のAPKを展開し、`allowBackup="false"`に修正してみます。
APKの展開には、[apktool](https://ibotpeaches.github.io/Apktool/)を使用しますが、Expoの開発フローとある程度調和させたいなとか、インストールが面倒だなということで、Node.jsでシェルスクリプトっぽいのを書いてみました。
.jarのライブラリファイルをそのまま含む[npmパッケージ](https://www.npmjs.com/package/apktool-jar)があるので、これを使います。

```console:パッケージのインストール
$ yarn add --dev child_process apktool-jar replace
```

ExpoでビルドしたAPKファイルを、ディレクトリを切って`dist/app.apk`としてプロジェクトディレクトリに移動するような感じを想定しています。

```js:editAndroidManifestXML.js
const childProcess = require('child_process');
const apktool = require('apktool-jar');
const replace = require('replace');

const directory = 'dist/'; // APKを入れるディレクトリ
const srcApkName = 'app.apk'; // 元のAPKファイル名
const distDirectory = 'dist/app/'; // 展開するディレクトリ
const revisedApkName = 'app-revised.apk'; // 再ビルドしたAPKファイル名
const distApkName = 'app-release.apk'; // 最適化・署名したAPKファイル名
const keystorePath = '/Users/.../.android/test.keystore'; // keystoreのパス
const keyAlias = 'TEST'; // keystoreのエイリアス
const keyPass = 'password'; // keystoreのパスワード

/**
 * spawnをpromise化
 * @param {string} command
 * @param {Array<string>} args
 * @returns {Promise<any>}
 */
const spawn = (command, args) => (
  new Promise((resolve)=>{
    const process = childProcess.spawn(command, args);
    process.stdout.on('data', (data) => console.log(`${data}`));
    process.stderr.on('data', (data) => console.log(`${data}`));
    process.on('exit', resolve);
  })
);

(async () => {
  // apktoolでAPKを展開
  await spawn('java', ['-jar', apktool.path, 'd', `${directory}${srcApkName}`, '-o', distDirectory, '-f']);

  // 展開されたAndroidManifest.xmlをいじる
  // allowBackupをfalseに変更
  replace({
    regex: 'android:allowBackup="true"',
    replacement: 'android:allowBackup="false"',
    paths: [`${distDirectory}AndroidManifest.xml`],
    silent: true
  });
  console.log('Replaced: allowBackup="true" ==> allowBackup="false"');

  // apktoolでAPKを再ビルド
  await spawn('java', ['-jar', apktool.path, 'b', distDirectory, '-o', `${directory}${revisedApkName}`, '-f']);

  // zipalignでAPKを最適化
  await spawn('zipalign', ['-f', '-v', '4', `${directory}${revisedApkName}`, `${directory}${distApkName}`]);

  // apksignerでAPKに署名
  await spawn('apksigner', ['sign', '--ks', keystorePath, '-v', '--v2-signing-enabled', 'true', '--ks-key-alias', keyAlias, '--ks-pass', `pass:${keyPass}`, `${directory}${distApkName}`]);

  // 署名を確認
  await spawn('apksigner', ['verify', '--print-certs', '-v', `${directory}${distApkName}`]);
})();
```

Expoでは署名も勝手にやってくれますが、再ビルドした後はkeystoreを用意し署名したりを自分でやらないといけません。
すでにあるものや、作成するなどして任意のkeystoreとエイリアスを使うようにコードは修正してください。
スクリプトの処理は

1. apktoolでAPKを展開
2. 展開されたファイルを編集
3. apktoolでAPKを再ビルド
4. zipalignでAPKを最適化
5. apksignerでAPKに署名

という流れになっています。

```console
$ node editAndroidManifestXML.js
```

で実行すると、`dist/app`ディレクトリにAPKが展開され、その中のAndroidManifest.xmlが書き換わります。

## `allowBackup="false"`の挙動を確認

全て問題なく処理が終わり、`dist/app-release.apk`が生成されたら、これを確認してみます。
先ほどAndroid端末にインストールした元のアプリをアンインストールし、修正後のアプリをインストールします。

その後、バックアップを実行してみると、

```console
$ adb shell bmgr backupnow <パッケージ名>
Running incremental backup for 1 requested packages.
Package <パッケージ名> with result: Backup is not allowed
Backup finished with result: Success
```

`Backup is not allowed`と表示され、`allowBackup="false"`の設定変更が効いているのが確認できました。