自分の iPhone では一度も失敗しないのに、レビューには「保存できません」と書かれる
個人開発で壁紙アプリをいくつか運用していると、ときどき不思議なレビューが届きます。「ボタンを押しても写真アプリに何も入りません」というものです。自分の手元の端末では、開発中も審査前も一度として失敗しません。にもかかわらず、星1つと一緒に「保存できない」という声だけが少しずつ積み上がっていきます。
原因を実機で追っていくと、たいていは画像ダウンロードの失敗でも、ストレージ不足でもありませんでした。expo-media-library の権限スコープが、端末の OS バージョンや、ユーザーが過去に一度押した許可ダイアログの選択によって、自分の想定と違う状態になっていたのです。
このすれ違いが厄介なのは、開発者の端末で再現しにくいことです。多くの開発者は最初の起動で「すべての写真へのアクセスを許可」を選んでいて、その後ずっとフルアクセスのまま開発を続けます。一方で実ユーザーの中には「選択した写真のみ」を選んだ人、Android 14 で「一部のみ許可」を選んだ人、そもそも保存だけしたいのに読み取り権限まで要求されて不信感から拒否した人が混ざっています。ここでは、この保存だけのための権限を正しく設計し、端末差を吸収する保存フローを組む手順を共有します。
なぜ端末によって保存の成否が変わるのか
expo-media-library の許可は、単純な「許可/拒否」の二択ではありません。OS ごとに段階があり、しかもその段階は年々細かくなっています。
iOS は写真ライブラリへのアクセスを4つの状態で持っています。フルアクセス、限定アクセス(ユーザーが選んだ写真だけ見える)、拒否、そして「追加のみ(add-only)」です。最後の追加のみは、保存はできるが既存の写真は一切読めない、という保存専用の権限です。これが今回の鍵になります。
Android はさらにバージョンで分岐します。Android 12 以前は WRITE_EXTERNAL_STORAGE、Android 13 は READ_MEDIA_IMAGES、Android 14 では「選択した写真のみ」にあたる部分許可(READ_MEDIA_VISUAL_USER_SELECTED)が加わりました。やっかいなのは、MediaStore 経由で自分のアプリが作った画像を Pictures/ 配下に書き込むだけなら、Android 10 以降は読み取り権限が本来は不要だという点です。にもかかわらず、読み取り権限まで一括で要求する実装になっていると、保存しか使わないユーザーにまで重い許可を求めてしまい、拒否率が上がります。
整理すると、保存の成否を分けているのは次の3つの組み合わせです。
| 要因 | 開発者の典型 | つまずくユーザー |
| iOS のアクセス状態 | フルアクセス | 限定アクセス / 追加のみ / 拒否 |
| Android のバージョン | 最新の検証端末 | Android 13・14 で挙動が変わる |
| 要求している権限の広さ | 読み書き一括 | 保存だけしたいのに読み取りまで拒否 |
保存だけなら、読み取り権限を求めてはいけない
ここがいちばん大事な設計判断です。壁紙やステッカー、生成画像を「写真に保存する」だけのアプリであれば、ユーザーのライブラリを読む必要はありません。expo-media-library には保存専用の許可を要求する writeOnly オプションがあり、これを使うと iOS では「追加のみ」、Android では書き込みに必要な最小限の権限だけを求められます。
import * as MediaLibrary from "expo-media-library";
// 保存専用の権限だけを要求する。
// iOS では add-only(追加のみ)、Android では書き込みに必要な最小権限になる。
async function ensureSavePermission(): Promise<boolean> {
// 現在の状態をまず確認する(毎回ダイアログを出さない)
const current = await MediaLibrary.getPermissionsAsync(true); // true = writeOnly
if (current.granted) return true;
// まだ決まっていない、または再要求が許される場合のみ要求する
if (current.canAskAgain) {
const next = await MediaLibrary.requestPermissionsAsync(true); // writeOnly
return next.granted;
}
// canAskAgain が false = ユーザーが恒久的に拒否済み。ダイアログは二度と出ない
return false;
}
getPermissionsAsync(true) と requestPermissionsAsync(true) の第一引数が writeOnly フラグです。ここを省略してデフォルト(読み書き両方)で要求してしまうと、保存しか使わないアプリでも iOS のフルアクセス確認や Android の読み取り権限が出てきて、ユーザーから見ると「壁紙アプリがなぜ私の写真を全部見たがるのか」という不信につながります。私は一度この設計を読み書き一括にしていて、保存ボタンの拒否率が体感で目に見えて高かったことがありました。writeOnly に切り替えてからは、許可ダイアログの文言自体が「写真の追加を許可しますか」に変わり、許可される率が上がりました。
app.json(または app.config)側でも、保存しか使わないことを明示しておきます。
{
"expo": {
"plugins": [
[
"expo-media-library",
{
"photosPermission": "保存した壁紙をあなたの写真に追加するために使用します。",
"savePhotosPermission": "壁紙を写真に保存するために使用します。",
"isAccessMediaLocationEnabled": false
}
]
]
}
}
savePhotosPermission が iOS の「追加のみ」ダイアログに出る文言です。ここを保存用途に限定した日本語で書いておくと、許可率にも審査時の説明にも効きます。
保存処理の本体——ダウンロードしてから createAssetAsync に渡す
リモートの画像 URL をそのまま createAssetAsync に渡すことはできません。先に端末のローカルにダウンロードして、そのファイルパスを渡す必要があります。expo-file-system で一時ファイルに落としてから保存し、終わったら片付けます。
import * as FileSystem from "expo-file-system";
import * as MediaLibrary from "expo-media-library";
type SaveResult =
| { ok: true }
| { ok: false; reason: "permission" | "download" | "save" };
async function saveRemoteImage(remoteUrl: string): Promise<SaveResult> {
// 1. 保存権限(writeOnly)を確保する
const allowed = await ensureSavePermission();
if (!allowed) return { ok: false, reason: "permission" };
// 2. 一時ファイルにダウンロードする
const fileName = `wallpaper-${Date.now()}.jpg`;
const localPath = FileSystem.cacheDirectory + fileName;
let downloaded;
try {
downloaded = await FileSystem.downloadAsync(remoteUrl, localPath);
if (downloaded.status !== 200) {
return { ok: false, reason: "download" };
}
} catch {
return { ok: false, reason: "download" };
}
// 3. 写真ライブラリに保存する
try {
await MediaLibrary.createAssetAsync(downloaded.uri);
return { ok: true };
} catch {
return { ok: false, reason: "save" };
} finally {
// 4. 一時ファイルを必ず片付ける(成否によらず)
await FileSystem.deleteAsync(downloaded.uri, { idempotent: true });
}
}
ポイントは、失敗の理由を permission / download / save に分けて返していることです。これをひとつの catch でまとめて「保存に失敗しました」とだけ表示してしまうと、原因の切り分けができなくなります。私は最初これを一括の try-catch で握りつぶしていて、レビューの「保存できない」が権限なのかネットワークなのか永遠に分からず、ログを仕込み直すはめになりました。理由を型で分けておくと、後述の UX 分岐がそのまま書けます。
アルバムに入れたいときの罠
「保存」だけならここまでで十分ですが、自分のアプリ専用のアルバムにまとめたい場合は一段増えます。createAssetAsync で作ったアセットを createAlbumAsync または addAssetsToAlbumAsync に渡します。ここで iOS と Android の差が出ます。
async function saveToAppAlbum(localUri: string, albumName: string) {
const asset = await MediaLibrary.createAssetAsync(localUri);
const album = await MediaLibrary.getAlbumAsync(albumName);
if (album == null) {
// アルバムがまだ無い場合は新規作成する
await MediaLibrary.createAlbumAsync(albumName, asset, false);
} else {
// 既存アルバムに追加する。
// 第三引数 copyAsset は iOS でのみ意味を持つ。
// false にすると元アセットを移動するため、写真アプリの「最近」から消えることがある
await MediaLibrary.addAssetsToAlbumAsync([asset], album, true);
}
}
createAlbumAsync と addAssetsToAlbumAsync の最後の引数 copyAsset は iOS でのみ作用します。true にするとアセットを複製してアルバムに入れるので、ユーザーの「最近の項目」にも残ります。false にすると移動になり、「保存したのにカメラロールに見当たらない」という別の問い合わせを生みます。Android では MediaStore の構造上この引数は無視され、常に追加扱いです。ここを Android だけで検証して false のまま出すと、iOS ユーザーから「消えた」報告が来る、というのが私が踏んだ順番でした。アルバム運用をするなら copyAsset は true を基本にすることをおすすめします。
iOS の限定アクセス(ユーザーが選択した写真だけ許可)の状態では、アルバムへの追加が期待通りに動かないことがあります。保存専用の writeOnly に寄せておくと、そもそも読み取り側の限定アクセスの影響を受けにくくなる、という意味でも writeOnly は安全側の選択です。
端末の解像度に合わせて保存する
壁紙のように画面いっぱいに使われる画像は、保存する前に端末の論理解像度へ寄せておくと、無駄に大きいファイルを写真に残さずに済みます。expo-image-manipulator でダウンサンプリングしてから保存します。
import * as ImageManipulator from "expo-image-manipulator";
import { Dimensions, PixelRatio } from "react-native";
async function downsampleForDevice(localUri: string): Promise<string> {
const { width, height } = Dimensions.get("screen");
const scale = PixelRatio.get();
// 端末の物理ピクセルを目安に、その2倍を上限とする
const targetWidth = Math.round(width * scale);
const result = await ImageManipulator.manipulateAsync(
localUri,
[{ resize: { width: targetWidth } }], // 高さは縦横比を保って自動計算される
{ compress: 0.9, format: ImageManipulator.SaveFormat.JPEG }
);
return result.uri;
}
ここで Dimensions.get("screen") と PixelRatio.get() を掛け合わせて物理ピクセルを推定しています。私自身、複数の壁紙アプリで新しい iPhone の解像度に追従する作業を毎年やってきましたが、端末ごとに保存サイズを最適化しておくと、ストレージに敏感なユーザーからの「容量を食う」という不満が減ります。元画像が端末より小さい場合は拡大しないよう、resize の width が元幅を超えるときは manipulate をスキップする分岐を足しておくと、ぼやけを防げます。
権限を拒否されたときに、ユーザーを行き止まりにしない
canAskAgain が false、つまりユーザーが恒久的に拒否した状態になると、requestPermissionsAsync を何度呼んでもダイアログは出ません。このときアプリ側で「許可してください」とだけ言って終わると、ユーザーはどこを触ればいいか分からず行き止まりになります。設定アプリへ直接送り出す導線が要ります。
import { Alert, Linking } from "react-native";
async function handleSavePress(remoteUrl: string) {
const result = await saveRemoteImage(remoteUrl);
if (result.ok) {
// 控えめな成功フィードバック(トーストなど)
return;
}
if (result.reason === "permission") {
Alert.alert(
"写真への保存が許可されていません",
"設定アプリから写真の追加を許可すると保存できます。",
[
{ text: "あとで", style: "cancel" },
{ text: "設定を開く", onPress: () => Linking.openSettings() },
]
);
return;
}
if (result.reason === "download") {
Alert.alert("画像を取得できませんでした", "通信環境を確認して、もう一度お試しください。");
return;
}
Alert.alert("保存に失敗しました", "時間をおいて、もう一度お試しください。");
}
Linking.openSettings() はアプリ個別の設定画面を開きます。失敗の理由ごとに文言と次の行動を変えているのが要点です。権限なら設定へ、通信なら再試行へと案内が分かれるだけで、同じ「失敗」でもユーザーが自力で復帰できる確率がはっきり変わります。先ほど保存処理で理由を型に分けておいたのは、この分岐をそのまま書くためでした。
実機での検証マトリクス
この種の不具合は1台では出ません。最低限、次の組み合わせを実機かシミュレータで通しておくと、レビューでの「保存できない」をほぼ潰せます。
| 端末・状態 | 確認する挙動 |
| iOS・追加のみ許可 | writeOnly で保存が通り、ライブラリ読み取りを要求しない |
| iOS・限定アクセス | 保存が成功し、アルバム追加で copyAsset=true なら最近にも残る |
| iOS・恒久拒否 | 設定アプリへの導線が出る |
| Android 13 | 保存が成功し、読み取り権限を過剰要求しない |
| Android 14・部分許可 | 保存だけなら部分許可の影響を受けず通る |
| 機内モード | download 理由で失敗し、再試行導線が出る |
個人開発で複数アプリを並行運用してきて学んだのは、保存機能の品質は「成功時の速さ」ではなく「失敗時に握りつぶさないこと」で決まる、ということです。Crash-free を監視している方は、createAssetAsync 周辺で例外を握りつぶしていないか(=失敗が無言で消えていないか)を一度確認してみてください。無言の失敗はクラッシュとして数えられないぶん、レビューにだけ現れます。
権限スコパーの設計に関する周辺トピックは、Rork アプリの ATT 許可ダイアログを適切なタイミングで出す設計 や、Rork の expo-image でディスクキャッシュが肥大化する問題の対処 も合わせて読むと、画像まわりの権限とストレージの全体像が掴めます。
まず最初の一歩として、いま自分のアプリの保存処理が requestPermissionsAsync() を writeOnly なしで呼んでいないか、その1行を確認してみてください。保存しか使わないのに読み書き一括で求めているなら、true を足すだけで拒否率が下がります。