壁紙アプリに「自分の写真も背景に使いたい」という要望をもらい、写真ライブラリへのアクセスを足したときのことです。手元の検証端末では問題なく動いていたのに、ある利用者から「写真を選んだはずなのに、アプリには何も出てこない」という報告が届きました。原因は、その方が iOS の権限ダイアログで「すべての写真へのアクセス」ではなく「選択した写真」を選んでいたことでした。アプリ側がフルアクセスを前提に「ライブラリ全体を読む」作りになっていたため、利用者が選んだ数枚を正しく扱えていなかったのです。
iOS の限定アクセス(選択した項目だけ許可)は、プライバシーを尊重する利用者ほど選びがちな選択肢です。ここでは Rork で生成した Expo アプリを題材に、限定アクセスを「例外」ではなく「前提」として組み込む設計を整理します。
「許可されたか」ではなく「どの程度許可されたか」
写真権限を granted の真偽だけで扱うと、限定アクセスを取りこぼします。iOS の写真権限には、実質的に3つの状態があります。
| 状態 | 利用者の選択 | アプリができること | 正しい導線 |
| フルアクセス | すべての写真 | ライブラリ全体を読める | 通常の一覧表示 |
| 限定アクセス | 選択した写真 | 選ばれた写真だけ読める | 選択分を表示+追加選択の導線 |
| 拒否 | 許可しない | 何も読めない | 設定アプリへの案内 |
落とし穴は、限定アクセスでも権限取得自体は「成功」する点です。granted === true だけを見ると、フルと限定の区別がつきません。限定アクセスなのにライブラリ全体を読もうとすると、選ばれた数枚しか返ってこず、実装によっては「空」に見えてしまいます。これを権限拒否と取り違えると、利用者に的外れな「設定で許可してください」を出してしまい、混乱させます。
3状態を正しく受け取る
Expo の expo-media-library は、フルと限定を区別できる情報を返します。accessPrivileges(iOS)を見て、all / limited / none を判定します。
// photos/permission.ts
import * as MediaLibrary from "expo-media-library";
export type PhotoAccess = "all" | "limited" | "denied";
export async function requestPhotoAccess(): Promise<PhotoAccess> {
// writeOnly を false にして読み取り権限を要求
const res = await MediaLibrary.requestPermissionsAsync(false);
return normalize(res);
}
export async function getPhotoAccess(): Promise<PhotoAccess> {
const res = await MediaLibrary.getPermissionsAsync(false);
return normalize(res);
}
function normalize(res: MediaLibrary.PermissionResponse): PhotoAccess {
if (res.status !== "granted") return "denied";
// iOS: accessPrivileges が "limited" のとき限定アクセス
// Android やフルアクセスの iOS では "all"(または未定義)
const priv = (res as any).accessPrivileges as string | undefined;
if (priv === "limited") return "limited";
return "all";
}
requestPermissionsAsync(false) の引数は「書き込み専用ではない」、つまり読み取りを含めて要求する指定です。ここを誤ると読み取りができず、原因の分かりにくい不具合になります。
画面は「選ばれた写真だけ」で成立させる
限定アクセスでは、getAssetsAsync が返すのは利用者が選んだ写真だけです。これを欠点ではなく前提として受け止め、画面を「選択分で成立する」設計にします。フルでも限定でも同じコードで動くのが理想です。
// photos/usePhotos.ts
import { useCallback, useEffect, useState } from "react";
import * as MediaLibrary from "expo-media-library";
import { getPhotoAccess, PhotoAccess } from "./permission";
export function usePhotos() {
const [access, setAccess] = useState<PhotoAccess>("denied");
const [assets, setAssets] = useState<MediaLibrary.Asset[]>([]);
const load = useCallback(async () => {
const a = await getPhotoAccess();
setAccess(a);
if (a === "denied") {
setAssets([]);
return;
}
// フルでも限定でも同じ呼び出し。限定なら選ばれた分だけが返る
const page = await MediaLibrary.getAssetsAsync({
mediaType: "photo",
sortBy: [["creationTime", false]],
first: 200,
});
setAssets(page.assets);
}, []);
useEffect(() => { load(); }, [load]);
return { access, assets, reload: load };
}
画面側は、access に応じて出すものを変えます。重要なのは、limited で assets が空でも「権限拒否」と扱わないことです。空=まだ何も選んでいない、なので「写真を追加」の導線を出します。
function PhotoGrid() {
const { access, assets, reload } = usePhotos();
if (access === "denied") {
// 本当に拒否された場合のみ設定アプリへ
return <DeniedView />;
}
return (
<>
{access === "limited" && (
<LimitedBanner onAddMore={async () => {
await presentLimitedPicker();
await reload(); // 追加選択後に読み直す
}} />
)}
{assets.length === 0 ? (
<EmptySelectionView onPick={async () => {
await presentLimitedPicker();
await reload();
}} />
) : (
<Grid data={assets} />
)}
</>
);
}
限定アクセス時の「追加選択」を促す
限定アクセスの利用者には、後から写真を選び直す手段を用意します。iOS には限定ライブラリの選択画面を再提示する仕組みがあり、Expo からも presentPermissionsPickerAsync で呼べます。
// photos/presentLimitedPicker.ts
import * as MediaLibrary from "expo-media-library";
import { Platform } from "react-native";
export async function presentLimitedPicker(): Promise<void> {
if (Platform.OS !== "ios") return; // この導線は iOS の限定アクセス向け
// 限定ライブラリの写真を選び直す画面を表示する
if (typeof (MediaLibrary as any).presentPermissionsPickerAsync === "function") {
await (MediaLibrary as any).presentPermissionsPickerAsync();
}
}
選び直しの直後に必ず reload() を呼ぶのが要点です。iOS は選択画面を閉じた時点でアクセス対象を更新しますが、アプリが手元に持っている assets は古いままです。読み直さないと、追加したはずの写真が反映されず「効かない」と感じさせます。
なお、毎回の起動で追加選択を促すのは煩わしく、限定アクセスを選んだ利用者の意思にも反します。バナーは控えめに常設し、能動的に「写真を追加」を押したときだけ選択画面を出す、という距離感が穏当です。
導入の手順
既存の写真機能に後付けする場合は、次の順で進めると安全です。私自身、個人開発の壁紙アプリへ「自分の写真を背景に」を足したときも、この順で組みました。
- 権限取得を
requestPhotoAccess に集約し、戻り値を all / limited / denied の3状態で受け取るようにします。
- 読み込みを
usePhotos に置き換え、限定アクセスで選んだ写真だけが返る前提に画面を作り替えます。limited かつ空でも拒否扱いにしないことを確認します。
- 限定アクセス時のみ「写真を追加」の導線(
presentLimitedPicker → reload)を出し、選び直し後に反映されることを実機で確認します。
この順なら、各段で挙動を確認でき、回避すべき「限定アクセスを拒否と誤認する」落とし穴を踏みません。写真機能は App Store のプライバシー審査でも見られる領域で、AdMob 等の広告 SDK と同様、権限の扱いは丁寧さが評価に効きます。
つまずきやすい点と本番運用での所感
実際に対応してみて感じた点を共有します。まず、検証端末でフルアクセスのまま開発を進めると、この問題はまず再現しません。私も最初はそうでした。限定アクセスは、一度フルで許可すると設定アプリ側で明示的に「選択した写真」に戻すまで切り替わらないため、テスト時は意図的に限定へ設定し直して確認する必要があります。ここを習慣にするだけで、報告にあったような不具合は出荷前に潰せます。
次に、文言です。「アクセスを許可してください」という一律のメッセージは、限定アクセスの利用者には的外れに響きます。私は限定アクセス時のバナーを「選んだ写真だけ使えます。増やすには『写真を追加』を押してください」という、状態に即した案内に変えました。利用者が自分の選択の結果を理解できると、不満が問い合わせに転じにくくなります。
最後に、プライバシーを尊重する設計は、長期的には信頼として返ってきます。限定アクセスをわざわざ選ぶ利用者は、アプリの振る舞いをよく見ています。その選択を尊重して「選んだ分だけで気持ちよく使える」状態を作ると、レビューでも好意的に受け止められました。権限まわりは地味な領域ですが、丁寧に向き合うほど効いてくると感じています。
写真権限の要点は、granted の二値で考えないことです。フル・限定・拒否の3状態を最初から前提に置けば、限定アクセスは例外ではなく、ごく普通に扱える選択肢になります。