スクリーンショットを貼り付けて送れるようにしてほしい、という要望は、チャット画面を持つアプリならほぼ必ず届きます。長らく React Native の TextInput はテキストしか受け取らず、自前でネイティブを書くか諦めるかの二択でした。
8月末に公開された expo-paste-input は、その二択の外側に道を作ったモジュールです。手元で組み込んでみたところ、貼り付け自体は驚くほど素直に動きました。詰まったのはその先です。貼り付けた直後は表示できるのに、少し時間を置いてから送信すると画像が届かないことがある。原因は、モジュールが返す file:// が何であるかを私が読み違えていたことでした。
この記事は、その読み違えを塞ぐところまでを含めた実装の記録です。
expo- で始まりますが、Expo SDK のモジュールではありません
最初に押さえておきたいのは、このモジュールの出どころです。
expo-paste-input は arunabhverma/expo-paste-input で公開されている MIT ライセンスのコミュニティ製モジュールで、Expo チームが提供する SDK の一部ではありません。Expo Modules API で書かれているため npx expo install expo-paste-input で入りますし、名前も expo- で始まります。この2つが揃うと、公式パッケージだと思い込みやすい構造になっています。
これは品質の話ではありません。サポートの主体が誰かという話です。SDK のバージョンが上がったときに追随してくれるのは Expo チームではなく、個人のメンテナです。個人開発でこの手のモジュールを本番に入れるかどうかを決めるとき、私は「壊れたときに自分で直せる範囲か」を判断の軸にしています。このモジュールは iOS と Android のネイティブ実装が数百行の規模で、貼り付けイベントを横取りして一時ファイルに書き出すという単一の責務しか持っていません。読める大きさです。だから採用しました。
逆に言えば、読まずに入れると、SDK 更新のたびに原因の分からないビルド失敗を抱えることになります。
入れた時点で、プレビュー中心の開発からは降りることになります
READMEに一行だけ、しかし決定的な注意が書かれています。ネイティブコードを含むため再ビルドが必要で、Expo Go では動きません。
# インストール
npx expo install expo-paste-input
# ネイティブを含むため、ここまで通して初めて動く
npx expo run:ios
npx expo run:android
Rork で生成したアプリを、プレビューで確認しながら育ててきた場合、この一行の意味は小さくありません。貼り付け機能を足した瞬間から、確認の手段が「プレビューで見る」から「開発ビルドを焼いて実機に入れる」に変わります。1回の確認にかかる時間が数秒から数分に伸びるということです。
私はこの切り替えを、機能単位ではなく時期単位で決めることを好みます。ネイティブモジュールを1つでも入れる予定があるなら、入れる直前ではなく、その週の頭に開発ビルドへ移してしまう。中途半端に両方の環境を行き来する期間が一番事故を生みます。実機確認の入口についてはRork Companion で iPhone 実機テストを始める手順 にまとめてありますが、そこから先へ進む判断がここです。
判断の材料を並べておきます。
状況 推奨 理由
まだ画面構成を試行錯誤している 貼り付けは後回し 反復速度を落とす投資は、形が決まってから
チャットが主機能で、要望が届いている 今すぐ開発ビルドへ移行 主機能の欠落は反復速度より重い
ネイティブモジュールがこれ1つだけ 移行して問題ない 依存が少ないほど再ビルドは安定する
審査提出が今週ある 提出後に着手 ネイティブ層の変更は提出直前に触らない
onPaste は3つの型を返します。テキストと画像で前提が逆です
使い方自体は簡潔です。自分の TextInput を TextInputWrapper で包み、onPaste を受け取ります。プロップの差し替えも、独自コンポーネントへの置き換えも不要です。
import { TextInputWrapper } from 'expo-paste-input' ;
import { TextInput } from 'react-native' ;
import { useState } from 'react' ;
type PasteEventPayload =
| { type : 'text' ; value : string }
| { type : 'images' ; uris : string [] }
| { type : 'unsupported' };
export function ChatInput () {
const [ text , setText ] = useState ( '' );
const [ attachments , setAttachments ] = useState < string []>([]);
const handlePaste = ( payload : PasteEventPayload ) => {
switch (payload.type) {
case 'text' :
// 注意: テキストは「挿入された後」に届きます。
// ここで setText すると二重に入るので、状態は触りません。
break ;
case 'images' :
// 画像は既定の貼り付けが止められた上で届きます。
// つまり、ここで受け取らなければ何も起きません。
setAttachments (( prev ) => [ ... prev, ... payload.uris]);
break ;
case 'unsupported' :
// ファイルや独自 UTI など。無言で捨てず、一言返します。
console. warn ( 'この形式の貼り付けには対応していません' );
break ;
}
};
return (
< TextInputWrapper onPaste = { handlePaste } >
< TextInput
value = { text }
onChangeText = { setText }
placeholder = "貼り付けてみてください"
multiline
/>
</ TextInputWrapper >
);
}
ここで一度立ち止まる価値があるのは、同じ onPaste なのにテキストと画像で前後関係が逆になっている点です。
テキストの貼り付けイベントは、文字が入力欄に挿入された後に飛びます。通知に近い扱いです。一方で画像の貼り付けは既定動作が抑止された上で飛んできます。TextInput は画像を描画できないので、モジュール側が代わりに引き受けている形です。
この非対称を知らずに case 'text' で setText(payload.value) と書くと、貼り付けた文字列が二重になります。読んだときに違和感のない書き方ほど間違っているという、気づきにくい種類の罠でした。
Paths.cache は、システムが消してよい場所だと明記されています
さて、本題です。
payload.uris に入ってくるのは file:// で始まるローカルパスです。README は「一時ファイルなので、永続化が必要なら移動してください」と一行で書いています。この一行を軽く読み流したのが、私の失敗でした。
Expo の FileSystem のドキュメント は、2つの保存領域をはっきり書き分けています。
領域 ドキュメントの定義 貼り付け画像を置いてよいか
Paths.cache端末の空き容量が少なくなったときにシステムが削除できる 場所 受け取った直後の一瞬だけ
Paths.documentシステムによって削除されないことが保証された 場所 送信まで持つならこちら
貼り付けの URI は前者に置かれます。つまり、ユーザーが画像を貼り付けてから送信ボタンを押すまでの間に、OS がそのファイルを回収する可能性が仕様として存在するということです。
これは机上の心配ではありません。個人開発で運営している壁紙アプリでは、ダウンロードした画像をキャッシュ領域に置いていた時期があり、「保存したはずの壁紙が翌日には見当たらない」という問い合わせが、端末の空き容量が少ないユーザーからだけ届きました。手元の開発機は空きが潤沢なので、何度試しても再現しません。原因にたどり着くまでに時間を溶かしました。以来、私は「消えても困らないか」を保存先を決める最初の問いにしています。
チャットの貼り付けは、まさに困る側です。下書きのまま数時間放置されることも、アプリがバックグラウンドに回ったまま翌朝まで置かれることも、普通に起きます。
受け取った直後に、documents へ移す
対処はひとつだけです。onPaste の中で、非同期処理を挟む前に移してしまう。
import { Directory, File, Paths } from 'expo-file-system' ;
const ATTACHMENT_DIR = 'pasted' ;
/**
* 貼り付け直後の一時 URI を、システムに消されない領域へ移す。
* 返り値は移動後の File 配列。移動に失敗したものは黙って捨てず、除外して返す。
*/
export async function persistPastedImages ( uris : string []) : Promise < File []> {
const dir = new Directory (Paths.document, ATTACHMENT_DIR );
// create() の既定は intermediates:false / idempotent:false。
// 何も指定しないと 2 回目の呼び出しで例外になります。
dir. create ({ intermediates: true , idempotent: true });
const saved : File [] = [];
for ( const uri of uris) {
const source = new File (uri);
// 一時ファイルは移動前に消えている可能性がある。ここで必ず確認する。
if ( ! source.exists) {
console. warn ( `貼り付け画像が既に存在しません: ${ uri }` );
continue ;
}
// 拡張子が付かないことがある。type(MIME)から補う。
const ext = source.extension || mimeToExtension (source.type) || '.png' ;
const name = `${ Date . now () }-${ Math . random (). toString ( 36 ). slice ( 2 , 8 ) }${ ext }` ;
try {
const destination = new File (dir, name);
// move は destination を受け取り、source 自身の uri を書き換える。
await source. move (destination);
saved. push (source);
} catch (error) {
console. warn ( `移動に失敗しました: ${ uri }` , error);
}
}
return saved;
}
function mimeToExtension ( mime : string ) : string | null {
switch (mime) {
case 'image/png' :
return '.png' ;
case 'image/jpeg' :
return '.jpg' ;
case 'image/gif' :
return '.gif' ;
case 'image/webp' :
return '.webp' ;
case 'image/heic' :
return '.heic' ;
default :
return null ;
}
}
塞いでいる穴を3つ挙げておきます。
1つ目は dir.create() の既定値です。intermediates も idempotent も既定が false なので、指定せずに書くと 2 回目の貼り付けで例外が飛びます。初回だけ通って以降落ちるという、テストで最も見逃しやすい形の失敗です。
2つ目はファイル名の衝突です。一時ファイルは短い名前で作られることがあり、そのまま移すと同名で上書きされます。RelocationOptions の overwrite は既定で false なので、上書きにはならず例外になります。時刻とランダム文字列を足して回避しています。
3つ目は拡張子です。クリップボード経由の画像は拡張子を持たないことがあります。拡張子のないファイルをそのままアップロードすると、サーバー側の MIME 判定に依存して壊れます。File は type に MIME を持っているので、そこから補えます。
move は新しい File を返しません。呼んだ File の uri が書き換わります
上のコードで saved.push(source) としているところに、違和感を持たれたかもしれません。移動先を destination として作ったのに、配列に入れているのは source の方です。
これは書き間違いではありません。File の move() は Promise<void> を返します。移動後のファイルを返してくれるわけではなく、呼び出した側のインスタンスの uri プロパティが新しい場所を指すように書き換わります。ドキュメントも「uri は読み取り専用だが、move などの呼び出しによって変わることがある」と明記しています。
const file = new File (Paths.cache, 'pasted-image.png' );
console. log (file.uri); // .../Caches/pasted-image.png
await file. move ( new Directory (Paths.document, 'pasted' ));
console. log (file.uri); // .../Documents/pasted/pasted-image.png ← 同じインスタンスが指す先が変わる
Web の File に慣れているほど、この破壊的な挙動は予想しにくいところです。私は最初 const moved = await source.move(destination) と書いて moved が undefined になり、しばらく理由が分かりませんでした。返り値を待つのではなく、await した後の source を使う。それだけです。
同期版の moveSync() もあります。貼り付け直後は数ファイル・数百KB程度なので同期でも体感に響きませんが、ユーザーが大量のスクリーンショットをまとめて貼る場面を想定するなら非同期版を選んでおく方が安全です。
送信するときと、やめるときの両方で後始末をする
移した先は「システムが消さない場所」です。つまり、自分で消さなければ永久に残ります。キャッシュに置いていたときは OS が掃除してくれていた分を、これからは自分で引き受けることになります。
import { Directory, File, Paths, UploadType } from 'expo-file-system' ;
/** 添付を送信し、成功したものから順に削除する */
export async function sendMessage ( text : string , files : File [], endpoint : string ) {
const uploaded : string [] = [];
for ( const file of files) {
const task = file. createUploadTask (endpoint, {
uploadType: UploadType. MULTIPART ,
fieldName: 'attachment' ,
parameters: { message: text },
onProgress : ({ bytesSent , totalBytes }) => {
console. log ( `${ file . name }: ${ bytesSent }/${ totalBytes }` );
},
});
const result = await task. uploadAsync ();
// 2xx 以外でも resolve する仕様。status を見ずに成功扱いしない。
if (result.status >= 200 && result.status < 300 ) {
uploaded. push (file.uri);
file. delete ();
} else {
throw new Error ( `アップロードに失敗しました (status: ${ result . status })` );
}
}
return uploaded;
}
/** 下書きを破棄したときに、残った添付を消す */
export function discardDraft ( files : File []) {
for ( const file of files) {
if (file.exists) {
file. delete ();
}
}
}
/** 起動時: 前回の異常終了で取り残されたファイルを掃除する */
export function sweepOrphanedAttachments ( maxAgeMs = 7 * 24 * 60 * 60 * 1000 ) {
const dir = new Directory (Paths.document, 'pasted' );
if ( ! dir.exists) return ;
const now = Date. now ();
for ( const item of dir. list ()) {
if (item instanceof File ) {
const modified = item.lastModified;
if (modified !== null && now - modified > maxAgeMs) {
item. delete ();
}
}
}
}
uploadAsync() は 2xx 以外のレスポンスでも reject せずに resolve します。ネットワーク層の失敗と、サーバーが 413 や 415 を返した失敗を、同じ try/catch で拾おうとすると後者をすり抜けます。status を明示的に見る必要があります。
sweepOrphanedAttachments は起動時に一度だけ呼ぶ想定です。アプリが強制終了した場合、送信も破棄もされないまま添付が残ります。1件あたりは小さくても、消えない場所に積み上がるものは必ずいつか効いてきます。この種の掃除処理は生成ループが緑を返した後に手元で回している受け入れ検査 の対象に入れておくと、実装したきり忘れる事故を防げます。
iOS と Android で分かれるところ
モジュールの内部は、プラットフォームごとにまったく違う仕組みで動いています。実機確認の順番を決めるうえで、ここは把握しておく価値があります。
項目 iOS Android
横取りする仕組み ネイティブの paste(_:) を捕捉 OnReceiveContentListener と ActionMode
取り出し元 UIPasteboardクリップボード/コンテンツAPI
キーボードのステッカー 専用のフックで別途対応 通常のクリップボード経路で届く
GIF アニメーション 保持される 保持される
固有の副作用 — 「画像を貼り付けられません」のトーストが出なくなる
実機で確かめる順番として、私は次の4つを固定しています。
Android で「画像を貼り付けられません」のトーストが消えているか 。これはモジュールが正しく組み込まれていれば必ず変わる、最も分かりやすい成功のしるしです。ここが変わらなければ、ネイティブ側が入っていません。
iOS でキーボードのステッカーを試す 。クリップボード経由の画像とは別経路なので、コピー&ペーストだけ確認して終えると、この経路が抜けます。README も「ステッカーは通常のクリップボード画像のようには公開されない」と書いており、実装が分かれている以上、確認も分ける必要があります。
アニメーション GIF を貼り、動いたまま表示されるか 。静止画に潰れる実装が多い領域なので、プレビュー側の描画も含めて確認します。
貼り付けてからアプリを一度バックグラウンドに送り、数分置いてから送信する 。移動の実装が効いているかは、この手順でしか分かりません。開発機は空き容量が潤沢なのでキャッシュの回収は起きにくく、コードを読んで正しいと判断するしかない領域ですが、少なくとも「移動後の URI で送信できているか」はここで確認できます。
なお、Web では何もしない実装になっています。クラッシュはしないので、共通コードから安全に import できます。Expo SDK 自体のパッチ追随については30日で9回出た Expo のパッチのうち自分のアプリに入っているのはどれか で扱っています。
実装より先に決めておきたいこと
このモジュールの導入で本当に難しいのは、貼り付けを動かすことではありませんでした。動くまでは半日もかかりません。難しいのは、受け取った file:// がどこに置かれているかを正しく理解し、それに合わせて保存先とライフサイクルを設計することです。
まず手を付けるなら、既存のコードで「一時ファイルの URI を、そのまま状態に保持しているところ」を探してみてください。画像ピッカー、カメラ、ダウンロード、そして貼り付け。同じ形の穴が他にも開いていることが多いはずです。
私自身、壁紙アプリで一度痛い目を見るまでこの区別を意識していませんでした。同じ回り道をされずに済めば嬉しく思います。お読みいただきありがとうございました。