configChanges に追加し、選択画面を開けなかったときは英語のエラーではなく日本語で回避策を出すようにしました。
お疲れ様です!IT業界で働くアライグマです!
個人開発しているAndroidアプリ「終業ログ」には、端末内の記録を1つのファイルに書き出し、あとで読み込んで戻すバックアップ機能があります。このバックアップで、端末の文字サイズを変えたあとだけ、保存先の選択が英語の内部エラーで失敗し、復元のファイル選択は無反応になる不具合がありました。アプリを完全に終了して開き直すと直るため、利用者からは原因も回避策も分からない状態でした。
文字サイズを変えた後、保存先選択は英語エラー、復元は無反応になった
終業ログは、1本目のアプリと同じExpo(React Native)の構成を流用して作ったアプリです。公開までの経緯は「Expoアプリを4日でGoogle Playに公開した記録」に書きました。記録は端末の中だけに保存しているので、機種変更や誤削除に備えて記録を守る手段は、利用者自身が行うバックアップと復元しかありません。
公開中の製品版で、文字サイズを変えるだけで再現した
不具合の記録には、公開中の製品版をAndroidエミュレーター(Pixel 7、Android 16)で動かして再現した手順が残っています。
- アプリを起動したまま、端末の文字サイズを変える(
adbで文字の拡大率を1.15にしてから1.0へ戻す) - アプリはホーム画面から表示し直される。プロセスは終了しておらず、同じものが動き続けている
- 設定の「バックアップと復元」から「保存先を選んで書き出す」を押す
- 続けて「復元するファイルを選ぶ」を押す
手順1で使ったコマンドは次のとおりです。
adb shell settings put system font_scale 1.15
adb shell settings put system font_scale 1.0
同じ画面の3つのボタンで、壊れ方がそれぞれ違った
手順3の「保存先を選んで書き出す」は、フォルダーを選ぶ画面が開かず、次の英語のエラーが画面にそのまま表示されました。
Call to function 'FileSystem.pickDirectoryAsync' has been rejected.
→ Caused by: java.lang.IllegalStateException: Attempting to launch an unregistered ActivityResultLauncher with contract expo.modules.filesystem.FilePickerContract … You must ensure the ActivityResultLauncher is registered before calling launch()
手順4の「復元するファイルを選ぶ」は、ファイルを選ぶ画面が開かず、エラーも案内も出ませんでした。押しても何も起きない状態です。一方、同じ画面にある「他のアプリで共有」は動きました。
アプリを強制終了して起動し直すと、フォルダー選択が開いてバックアップファイルを保存でき、ファイル選択も開きました。「アプリを開き直せば直る」のですが、それを利用者が推測するのは無理です。
IT女子 アラ美原因は、文字サイズの変更でAndroidが画面を作り直していたこと
設定が変わると、Androidは画面の入れ物を作り直す
Androidのアプリは、画面を表示する入れ物として「Activity」を持っています。Expoで作ったアプリも、中身はReact Nativeの画面ですが、外側では MainActivity という1つのActivityが動いています。
端末の文字サイズ、表示サイズ、言語などの設定が変わると、Androidは既定ではこのActivityを一度破棄して作り直します。新しい設定で画面を組み立て直すためです。ただし、アプリ側が「この種類の変更は自分で受け止めるので作り直さなくてよい」と宣言した変更は除かれます。その宣言が、マニフェストの android:configChanges です。
公開版のマニフェストを確認すると、MainActivity の configChanges には画面の向きやキーボードなどは含まれていましたが、文字サイズ(fontScale)・表示サイズ(density)・言語(locale)は入っていませんでした。そのため、文字サイズを変えるとActivityが作り直されます。再現手順で「アプリがホーム画面から表示し直された」のはこのためです。
ファイル選択の窓口が、作り直し前の画面に結び付いたまま残った
フォルダーやファイルを選ぶ画面は、アプリの外にあるAndroidのシステム画面です。アプリから別の画面を開いて結果を受け取るには、「ActivityResultLauncher」という窓口を事前にActivityへ登録しておき、そこから起動します。
使っていた expo-file-system(57.0.7)のAndroid側を読むと、この窓口をモジュールの定義で一度登録し、フォルダー選択とファイル選択の両方でそれを使っています。
RegisterActivityContracts {
filePickerLauncher = registerForActivityResult(
FilePickerContract(this@FileSystemModule)
)
}
AsyncFunction("pickDirectoryAsync") Coroutine { initialUri: Uri? ->
val result = filePickerLauncher.launch(...)
...
}
エラーメッセージは「登録されていない窓口を起動しようとした」という意味です。Activityが作り直されたあとも、プロセスが同じなので、作り直し前のActivityに登録した窓口から起動しようとして失敗したと考えています。ただし、これはエラー内容と実装から推定した原因です。上流の既知の不具合や修正があるかは、調べた範囲では見つけられませんでした。
なお、以前書いた「Expo Routerの言語切り替えで入力が消えた話」も「画面の作り直し」が原因でしたが、あちらはReact側で画面を組み直していた話です。今回はAndroid自身がActivityを作り直しており、React側のコードを見ても原因は出てきません。



復元が無反応だったのは、失敗が「キャンセル」として返っていたから
書き出しと復元で、同じ失敗の見え方が違った
書き出しも復元も、システムの選択画面を同じ窓口から開いています。それなのに、書き出しは英語のエラーが出て、復元は何も起きませんでした。違いは、アプリが呼んでいた expo-file-system の関数の作りにありました。
書き出しで使っていたフォルダー選択は、失敗するとそのまま例外を投げます。アプリ側はその例外の message を画面に表示していたので、ネイティブ側の英語のエラーがそのまま出ました。
ファイル選択の関数は、どんな失敗でもキャンセル扱いにしていた
復元で使っていた File.pickFileAsync は、結果を「選んだファイル」か「キャンセルされた」かの形で返す関数です。中を読むと、例外が起きた場合は種類を問わず「キャンセルされた」として返していました。
// expo-file-system 57.0.7 の File.pickFileAsync(抜粋)
try {
const file = await ExpoFileSystem.pickFileAsync(options);
return { result: new File(file.uri), canceled: false };
} catch (e) {
return { result: null, canceled: true };
}
アプリ側は canceled が true なら「利用者が選択をやめた」と判断し、何も表示せずに終わる作りでした。利用者が自分でやめた場合はそれで正しいのですが、選択画面を開けなかった場合も同じ扱いになります。その結果、復元のボタンは押しても何も起きない状態になっていました。
ネイティブの関数を直接呼び、キャンセルと失敗を分けた
修正では、ラッパーの File.pickFileAsync をやめ、ネイティブモジュールの pickFileAsync を直接呼ぶようにしました。ネイティブ側は、利用者がやめた場合に ERR_PICKER_CANCELLED というコードの例外を投げます。このコードのときだけキャンセルとして扱い、それ以外の例外は呼び出し元へそのまま伝えます。
function isCancel(cause: unknown): boolean {
return (cause as { code?: unknown } | null)?.code === "ERR_PICKER_CANCELLED";
}
try {
const picked = await getNativeFileSystem().pickFileAsync({
mimeTypes: [MIME_TYPE, "text/plain", "application/octet-stream"],
multipleFiles: false,
});
...
} catch (cause) {
if (isCancel(cause)) return null; // 利用者が選択をやめた
throw cause; // 開けなかった等の失敗は伝える
}



configChangesに文字サイズなどを足し、失敗時は日本語で回避策を出した
修正は1回の変更にまとめて入れました。中身は、画面の作り直しを防ぐこと、前の章で書いたキャンセルと失敗の区別、そして失敗したときの表示の見直しの3つです。
設定プラグインで、作り直さない変更を追加した
Expoではマニフェストを直接書かず、ビルド時に設定プラグインで書き換えます。MainActivity の configChanges に、文字サイズ・表示サイズ・言語・文字の向き(右から左など)の4つを足すプラグインを追加しました。既に入っている値は残し、重複も足さないようにしています。
const { withAndroidManifest } = require("expo/config-plugins");
const CONFIG_CHANGES_TO_ADD = ["fontScale", "density", "locale", "layoutDirection"];
function updateConfigChanges(currentValue) {
const existing = currentValue
? currentValue.split("|").map((s) => s.trim()).filter(Boolean)
: [];
return Array.from(new Set([...existing, ...CONFIG_CHANGES_TO_ADD])).join("|");
}
module.exports = (config) =>
withAndroidManifest(config, (config) => {
const mainActivity = config.modResults.manifest.application?.[0]?.activity?.find(
(a) => a.$?.["android:name"] === ".MainActivity",
);
if (mainActivity?.$) {
mainActivity.$["android:configChanges"] = updateConfigChanges(
mainActivity.$["android:configChanges"],
);
}
return config;
});
あわせて app.config.js のプラグイン一覧に登録しています。
英語のエラーをそのまま出さず、開き直しを案内する
これまでは、例外の message を何でも画面に出していました。修正後は、アプリ自身が利用者向けに書いたエラーだけをそのまま出し、選択画面を開けなかった失敗には「選択画面を開けませんでした。アプリを開き直してからもう一度お試しください。」と表示します。それ以外の想定外のエラーは、「書き出せませんでした。もう一度お試しください。」のような操作ごとの日本語の文に置き換えます。復元のファイル選択で起きた例外は、障害の記録にも残すようにしました。
マージ前のAIレビューで、共有できない端末の案内が消える問題を直した
この表示の見直しには、マージ前のAIレビューで指摘が1件付きました。「共有を使えない端末では、保存先を選んで保存してください」という案内は、アプリが書いた文なのに普通のエラーとして投げていたため、新しい判定では汎用の文に置き換わってしまう、という指摘です。同じ変更の中で、この案内もアプリ自身のエラーとして投げるように直しました。
自動テストで確かめ、修正後の実機での確認記録は残っていない
確認は自動テストで行いました。プラグインが4つの値を重複なく足すこと、選択画面の失敗時に日本語の案内が出ること、復元が無反応にならないこと、想定外のネイティブのエラーで英語を出さないことを検証しています。修正を入れた変更には、エミュレーターで文字サイズを変えたときのAndroid側の動きは未確認と書かれていました。この修正はその後の新しい版に含めて審査へ出しましたが、修正後に文字サイズを変えて試した記録は残っていません。



まとめ
端末の文字サイズを変えると、Androidはアプリの画面(Activity)を作り直します。終業ログでは、その後に expo-file-system のフォルダー・ファイル選択を開こうとすると、「unregistered ActivityResultLauncher」で失敗していました。書き出しは英語のエラー、復元は無反応と見え方が違ったのは、ファイル選択の関数が失敗も「キャンセル」として返していたためです。
修正では、文字サイズ・表示サイズ・言語などの変更で画面を作り直さないよう configChanges に追加しました。あわせて、キャンセルと失敗を区別し、開けなかったときは日本語で「アプリを開き直してください」と案内するようにしています。自動テストでは確かめましたが、修正後に実機で文字サイズを変えて試した記録はまだありません。
Expoで作ったアプリでも、外側ではAndroidのActivityが動いています。文字サイズを変えたあとにだけ壊れる不具合は、React側のコードではなく、マニフェストの configChanges から疑うと早いと思います。












