アプリは Android 9 では動作し、Android 10 への更新後、同じ端末で落ちます。プロファイルは手つかずで、証明書も同じ、クラッシュは認証トラフィックより前に起きます。logcat はまずヌルポインタを示し、その数行上に Cannot run program ...: error=13, Permission denied があります。このメッセージはファイルの実行に関するもので、設定の読み取りではありません。

エラー 13 が本当に拒否しているもの

Android 10(API レベル 29)は、アプリが自身のデータディレクトリに書き込んだファイルの実行を止めました。この規則は書き込み可能でかつ実行可能なストレージを対象にしており、まさに assets からコピーするランチャーが作るものがそれです。ics-openvpn コアの古いビルドは、初回起動時に APK から実行ファイル openvpn を展開し、ProcessBuilder で起動します。展開自体は今も成功し、拒否されるのは実行のほうです。API 29 を対象とするアプリは、古いリリースで出荷された端末で動いていてもこの規則を継承するため、誰も触っていないビルドが更新で壊れることがあります。

この拒否に特定のフォルダ名だけに当てはまる点はありません。アプリがデータディレクトリ配下に作ったファイルは、パスに関係なく実行不可として扱われるため、getFilesDir()、getCacheDir()、独自のサブディレクトリはいずれも同じように失敗します。

ヘルパーのクラッシュとプロファイルの問題を切り分ける

上からスクロールするのではなく、プロセス起動前後のログを読みます。探すべき署名は error=13 の行で、その上のスタックトレースは通常 ProcessBuilder を呼び出したランチャーのメソッドを示します:

adb logcat -d | grep -n -i -E "cannot run program|permission denied|startOpenVPN"

ヘルパーが一度も起動していなければ、OpenVPN のログ出力はまったく見えません:暗号のネゴシエーションも、プッシュされたルートも、DNS 設定もありません。逆にそれらの行が現れて失敗が後から来るなら、バイナリは動作しており、問題はプロファイル、サーバー、またはルーティング層にあります。この一点の切り分けだけで、決して原因ではなかった .ovpn ファイルの編集に費やすはずだった時間を大きく節約できます。下の二つの修正のうち、どちらが本当に必要かも分かります。

テストしている端末の API レベルを、ビルド設定のものではなく確認してください。API 28 のエミュレータイメージでは、この制限が 29 以降でしか効かないため何も再現されません。同時にビルドファイルのターゲット SDK も、それを引き上げることがこの規則を招き入れるため確認してください。

adb shell getprop ro.build.version.sdk

バイナリを別の場所にコピーしても助からない理由

明白な回避策、つまりヘルパーを別の書き込み可能なディレクトリに書く方法は、元の方法が失敗したのと同じ理由で失敗します。Android は、インストーラがパッケージするもので実行時に書き込まれるわけではないという理由で、アプリのネイティブライブラリディレクトリにインストールされた共有ライブラリの実行を今も許可しています。ランチャーが指すべきはそのディレクトリだけです。実行時に自分で展開するものは、どう名付けても拒否される分類に残ります。

外部ストレージはさらに悪く、noexec でマウントされているため、そこに置いたヘルパーはどのアプリからも起動できません。アプリデータを消去しても古い動作は戻らず、次の起動で同じ実行不可のコピーが再び作られるだけです。

ランチャーをパッケージ済みライブラリに向ける

ヘルパーを共有ライブラリ名に改名し、サポートするすべての ABI 向けに jniLibs の下へ出荷し、そのうえでランチャーが API 28 以降でインストール済みライブラリのパスを返すようにします:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
    return new File(context.getApplicationInfo().nativeLibraryDir,
                    "libovpnexec.so").getPath();
}

そのパスに実在のファイルが入るには、インストール時にライブラリが展開されている必要があるため、android:extractNativeLibs="true" を <application> 要素に設定します。マニフェストマージャが別の値を持ち込む場合は、その隣に tools:replace="android:extractNativeLibs" を追加します。application 要素リファレンス には、この属性が何を制御するかが記載されています。

ランチャーを手でパッチするときは、ローカル変数の重複に注意してください。古いソースは同じメソッドの後半で nativeAPI を宣言しており、二度目の宣言は実行時エラーではなくコンパイルエラーになるため、ビルドはすぐ失敗し、間違いは簡単に見つかります。Gradle ブロックの ABI 一覧は、追加するフォルダと揃えておきましょう。

リリース成果物をテストビルドと一致させる

デバッグ APK ではトンネルが張れるのにリリース bundle ではまだクラッシュすることがありますが、これは bundle がネイティブライブラリを別の形で梱包し、アーカイブから圧縮したまま配信することがあるためです。bundle 由来のビルドだけが失敗するなら、これを gradle.properties に追加して再ビルドしてください:

android.bundle.enableUncompressedNativeLibs=false

そのうえで、インストールされたパッケージが期待どおりの ABI を報告しているか確認し、両方を出荷するなら 32 ビットと 64 ビットの端末で同じ確認を繰り返します。パッケージマネージャは選択した ABI を表示するので、32 ビットに落ちた端末はクラッシュレポートではなくここに現れます:

adb shell dumpsys package com.example.openvpn | grep -i primaryCpuAbi

ある ABI 向けのライブラリが欠けているとその端末だけで元の権限エラーが再現するため、テスト端末一台の成功は証明になりません。マニフェストのフラグ、ライブラリ名、bundle の設定をまとめて出荷し、一つだけ変えて他を忘れれば同じクラッシュに戻ります。長期的な修正は、パッチを抱え続けるのではなく、メンテナンスされているコアのリリースへ移行することです。

関連記事