ある朝、自分の手記アプリを開いたまま電話に出ようとして、ホームボタンを二度押ししました。アプリスイッチャーに並んだサムネイルの中に、さっきまで書いていた個人的な一文がそのまま写っていました。家族に画面を覗かれて困るような内容ではありませんでしたが、「これは本人以外の手に渡るのを前提にしていなかった画面だ」と気づいて、少し背筋が冷えました。
iOS はアプリをホームへ戻した瞬間、その時点の画面を撮影してアプリスイッチャーのプレビューに使います。この画像はメモリ上だけのものではなく、ディスクに保存されます。つまり、パスコードでロックする前のホーム画面に、あなたのアプリの中身がしばらく残り続けるということです。手記、引き寄せの記録、健康データ、決済直前の金額——本人だけが見る前提で作った画面ほど、ここに残ると困ります。
私自身、個人開発で手記系・引き寄せ系のアプリを複数運用していますが、この「背景に回した瞬間」の守りは、Rork が生成した React Native のコードにも、Rork Max が書き出した Swift にも、初期状態では入っていません。守りの入り口は、その瞬間に画面へ目隠しを差し込むことにあります。つまずきやすい順序の問題から順に組み立てていきます。
守るべきは「表示している間」ではなく「離れる瞬間」
セキュリティ対策というと、画面を表示している最中の話だと考えがちです。けれども今回守りたいのは、ユーザーがアプリから離れる遷移の一瞬です。iOS はアプリが前面から外れるとき、UI のスナップショットを撮ってアプリスイッチャーに使います。撮影されるのは「完全に背景へ落ちた後」ではなく、その手前の inactive 状態に入るタイミングです。
React Native の AppState は、この遷移を3つの値で表します。前面で動いている active、操作を受け付けない過渡状態の inactive(iOS のみ)、そして背景の background です。コントロールセンターを下ろした、電話がかかってきた、アプリスイッチャーを開いた——こうした場面ではまず inactive を通過します。
ここに最初の落とし穴があります。background になってから目隠しを出すと、スナップショットの撮影に間に合いません。撮影はすでに inactive の段階で済んでいるからです。守りの入り口は background ではなく inactive だ、という一点を最初に押さえておきます。
iOS:inactive で目隠しオーバーレイを出す
まず、アプリのルートに常駐する目隠し用のオーバーレイを用意します。active のときは透明(実質非表示)、それ以外のときは画面全体を覆う、という素直な作りです。状態管理は外部ライブラリを使わず、軽量な AppState 購読フックにまとめます。
// hooks/usePrivacyShield.ts
import { useEffect, useRef, useState } from "react" ;
import { AppState, AppStateStatus } from "react-native" ;
// active 以外(inactive / background)のときに true を返す。
// iOS のスナップショットは inactive で撮られるため、background を待ってはいけない。
export function usePrivacyShield ( enabled : boolean ) {
const [ shielded , setShielded ] = useState ( false );
const appState = useRef < AppStateStatus >(AppState.currentState);
useEffect (() => {
if ( ! enabled) {
setShielded ( false );
return ;
}
const handle = ( next : AppStateStatus ) => {
// active へ戻った瞬間だけ目隠しを外す。
// それ以外の遷移(inactive / background)はすべて覆う側に倒す。
setShielded (next !== "active" );
appState.current = next;
};
// 初期表示時の取りこぼし防止(起動直後が inactive のことがある)。
setShielded (AppState.currentState !== "active" );
const sub = AppState. addEventListener ( "change" , handle);
return () => sub. remove ();
}, [enabled]);
return shielded;
}
このフックをアプリ最上位の1か所で使い、覆う UI を被せます。画面ごとに置くと、遷移の瞬間に「今どの画面か」を判定する余裕がないため、必ずルートに置きます。
// components/PrivacyShield.tsx
import { View, StyleSheet, Image } from "react-native" ;
import { usePrivacyShield } from "../hooks/usePrivacyShield" ;
export function PrivacyShield ({ enabled } : { enabled : boolean }) {
const shielded = usePrivacyShield (enabled);
if ( ! shielded) return null ;
// ブランドのロゴ1枚だけを中央に置く。中身は一切描かない。
return (
< View style = { styles.cover } pointerEvents = "none" >
< Image
source = { require ( "../assets/shield-logo.png" ) }
style = { { width: 96 , height: 96 , opacity: 0.9 } }
resizeMode = "contain"
/>
</ View >
);
}
const styles = StyleSheet. create ({
cover: {
... StyleSheet.absoluteFillObject,
backgroundColor: "#0E1116" ,
alignItems: "center" ,
justifyContent: "center" ,
zIndex: 9999 ,
},
});
ルートではこう組み込みます。enabled には「いま機密画面にいるか」を渡せるようにしておくと、後で画面単位の制御に発展させられます。
// app/_layout.tsx(Expo Router の例)
import { Stack } from "expo-router" ;
import { PrivacyShield } from "../components/PrivacyShield" ;
export default function RootLayout () {
return (
<>
< Stack />
{ /* まずはアプリ全体で常に有効化。後で画面単位に絞る */ }
< PrivacyShield enabled = { true } />
</>
);
}
ここまでで、ホームに戻したりアプリスイッチャーを開いたりした瞬間、画面はロゴ1枚の目隠しに切り替わり、撮影されるスナップショットも目隠し後の状態になります。
なぜ background だけでは守れないのか
実際に最初の実装でつまずいたのが、この順序でした。最初は「背景に落ちたら隠す」と考えて next === "background" のときだけ覆っていたのですが、アプリスイッチャーのサムネイルには中身がそのまま残りました。
// ❌ うまくいかない:background を待つと撮影に間に合わない
const handle = ( next : AppStateStatus ) => {
setShielded (next === "background" );
};
// ✅ 正しい:active 以外はすべて覆う(inactive を逃さない)
const handle = ( next : AppStateStatus ) => {
setShielded (next !== "active" );
};
理由は前述のとおり、iOS のスナップショットは background に到達する前の inactive で撮られるからです。active 以外をすべて目隠し側に倒すことで、inactive を漏らさず捕まえます。active に戻ったときだけ外す、という対称な書き方にしておくと、コントロールセンターを少し下ろしてすぐ戻したような細かい遷移でも破綻しません。
なお、inactive のたびに一瞬目隠しがちらつくのを嫌って遅延を入れたくなりますが、ここは入れてはいけません。遅延を挟むと、その隙にスナップショットが撮られます。ちらつきは「守れている証拠」と捉え、オーバーレイのデザインをブランドに馴染ませて違和感を減らす方向で対処します。
Android:FLAG_SECURE はまったく別の仕組み
ここで安心して終わらせると、Android 側が無防備なまま残ります。Android の「最近使ったアプリ」(Recents)も同じようにプレビューを表示しますが、守り方は iOS と別物です。
Android には FLAG_SECURE というウィンドウフラグがあり、これを立てると Recents のプレビューが空白(ブランク)になり、さらにスクリーンショットと画面録画も OS レベルで禁止されます。Expo では expo-screen-capture の preventScreenCaptureAsync() がこのフラグを設定します。
npx expo install expo-screen-capture
// Android 向け:FLAG_SECURE を立てて Recents を空白化+スクショ禁止
import * as ScreenCapture from "expo-screen-capture" ;
import { Platform } from "react-native" ;
export async function enableAndroidSecureFlag () {
if (Platform. OS !== "android" ) return ;
// これだけで Recents のサムネイルは空白になり、スクショ・録画も止まる
await ScreenCapture. preventScreenCaptureAsync ( "privacy-screen" );
}
export async function disableAndroidSecureFlag () {
if (Platform. OS !== "android" ) return ;
await ScreenCapture. allowScreenCaptureAsync ( "privacy-screen" );
}
ここで大事なのは、iOS のオーバーレイと Android の FLAG_SECURE は守る対象が違う、という点です。FLAG_SECURE は iOS では Recents のスナップショットには効きません(iOS にこのフラグ相当はありません)。逆に、iOS で作ったオーバーレイは Android の Recents 空白化やスクショ禁止までは担いません。つまり両方を併用してはじめて、両プラットフォームで「アプリスイッチャー/Recents に中身を残さない」が揃います。片方だけ実装して全体を守ったつもりになるのが、いちばん起こりやすい事故です。
アプリ全体ではなく、機密画面だけに絞る
FLAG_SECURE をアプリ全体で立てっぱなしにすると、ユーザーはどの画面でもスクリーンショットを撮れなくなります。設定画面の不具合を報告したい、お気に入りの一覧を友人に見せたい——そうした正当な操作まで奪うのは行き過ぎです。私は、機密画面に入ったときだけフラグを立て、離れたら戻す方式にしています。
Expo Router や React Navigation の useFocusEffect を使うと、画面の表示・非表示に合わせてきれいに制御できます。
// 機密画面のコンポーネント内(例:手記の編集画面)
import { useFocusEffect } from "expo-router" ;
import { useCallback, useContext } from "react" ;
import {
enableAndroidSecureFlag,
disableAndroidSecureFlag,
} from "../lib/secureFlag" ;
import { PrivacyContext } from "../lib/PrivacyContext" ;
function JournalEditorScreen () {
const { setSensitive } = useContext (PrivacyContext);
useFocusEffect (
useCallback (() => {
// この画面に入ったら:iOS は目隠しを有効化、Android は FLAG_SECURE
setSensitive ( true ); // iOS オーバーレイの enabled を true に
enableAndroidSecureFlag (); // Android の Recents 空白化+スクショ禁止
return () => {
// 画面を離れたら元に戻す
setSensitive ( false );
disableAndroidSecureFlag ();
};
}, [setSensitive])
);
// ...画面本体
}
PrivacyContext は、先ほどの PrivacyShield の enabled を切り替えるためのごく薄い Context です。setSensitive(true) で iOS のオーバーレイを有効化し、同じ瞬間に Android のフラグも立てる。これで「機密画面にいる間だけ、両プラットフォームで守る」がそろいます。離脱時の戻し忘れが事故につながるので、必ず useFocusEffect のクリーンアップ関数で対にして戻します。
撮られてしまった後を検知する
防ぐだけでなく、「撮られた」ことに気づける設計も、画面によっては価値があります。expo-screen-capture には、スクリーンショットが撮られた瞬間に発火するリスナーがあります。本人が自分の記録を保存する分には問題ありませんが、たとえば共有された家族アカウントで個人的な記録が撮られたとき、本人に控えめに知らせる、といった使い方ができます。
import * as ScreenCapture from "expo-screen-capture" ;
import { useEffect } from "react" ;
function useScreenshotNotice ( onShot : () => void ) {
useEffect (() => {
// iOS / Android ともにスクショ検知が可能(録画検知は別途 isCaptured を併用)
const sub = ScreenCapture. addScreenshotListener (() => {
onShot ();
});
return () => sub. remove ();
}, [onShot]);
}
検知はあくまで通知や記録のトリガーであり、スクショそのものを止める力はありません(止めるのは Android の FLAG_SECURE 側の役割です)。ですから、検知は「止められない iOS のスクショに対して、本人にだけそっと知らせる」補助線として使うのが現実的です。過剰な警告は不安をあおるだけなので、文面は淡々と、一度きりにとどめます。
どこに何を適用するかの判断表
すべての画面を一律に守る必要はありません。守りを強くするほど、ユーザーの正当な操作(スクショでの共有や不具合報告)を奪います。画面の性質ごとに、どこまで適用するかを分けて考えます。
画面の性質
iOS 目隠しオーバーレイ
Android FLAG_SECURE
スクショ検知
手記・個人的な記録の入力/閲覧
適用
適用
任意(そっと通知)
健康・メンタル・身体データ
適用
適用
任意
決済直前・金額・カード情報
適用
適用
適用しない(妨げになりがち)
一般的な一覧・設定・ヘルプ
適用しない
適用しない
適用しない
壁紙・画像の閲覧(共有が前提)
適用しない
適用しない(共有を奪わない)
適用しない
判断の軸はひとつです。「この画面の中身が、本人以外の目に一瞬でも触れて困るか」。困るなら守り、共有してほしい画面なら守らない。私は壁紙アプリのように見せて広げてほしいアプリでは一切適用せず、個人的な記録を扱うアプリの該当画面にだけ絞っています。判断に迷う画面があれば、私はこの場合は守る側に倒すことを推奨します。あとから外すのは簡単ですが、一度漏れたスナップショットは取り戻せないからです。
まず1画面で試す
最初の一歩としては、アプリの中でいちばん個人的な1画面——手記の編集画面、健康データの詳細など——を選び、そこに useFocusEffect で iOS オーバーレイの有効化と Android の FLAG_SECURE を対で仕込んでみてください。実機をホームに戻し、アプリスイッチャー(iOS)と Recents(Android)の両方でサムネイルが目隠し/空白になることを目視で確認します。ここが確認できれば、残りの機密画面へ広げる判断は、上の表に沿って静かに進められます。
守りの本質は派手な機能ではなく、ユーザーが「ここに書いたものは自分だけのものだ」と安心して手を動かせること、その一点だと考えています。同じように個人的な記録を扱うアプリを作っている方の参考になれば幸いです。