●PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16●VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to miss●EXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration plan●APPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spent●EXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInput●EAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates●PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16●VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to miss●EXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration plan●APPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spent●EXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInput●EAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates
Adding image paste with expo-paste-input, and moving the disappearing file:// URIs out of cache
How to let users paste images and GIFs into a chat input with expo-paste-input. The URIs that onPaste hands you are temporary files that can vanish before the user hits send. Here is the relocation code and the order I verify it on real devices.
Let me paste a screenshot and send it. If your app has a chat screen, that request arrives sooner or later. For a long time React Native's TextInput accepted text and nothing else, so the answer was either to write native code yourself or to say no.
expo-paste-input, published at the end of August, opens a third door. When I wired it up, the pasting itself worked almost immediately. What cost me time came afterward. The image showed up fine right after pasting, but if the user waited a while before sending, it sometimes never arrived. The cause was that I had misread what the file:// URI the module hands back actually is.
This is the record of that implementation, including the part that closes the hole.
It starts with expo-, but it is not an Expo SDK module
The first thing worth knowing is where this module comes from.
expo-paste-input is an MIT-licensed community module published at arunabhverma/expo-paste-input. It is not part of the SDK that the Expo team ships. Because it is written with the Expo Modules API, npx expo install expo-paste-input works, and the name begins with expo-. Those two facts together make it very easy to assume it is first-party.
This is not a comment on quality. It is a question of who maintains it. When the SDK version moves, the person who keeps up is an individual maintainer, not the Expo team. As an indie developer deciding whether a module like this belongs in production, my test is whether I could fix it myself if it broke. The native implementations here are a few hundred lines per platform, and the module has exactly one responsibility: intercept the paste event and write the result to a temporary file. That is a readable amount of code. So I adopted it.
The flip side is real. Install it without reading it, and every SDK bump becomes a build failure you cannot explain.
Installing it means stepping off the preview loop
The README contains one short but decisive line: the library ships native code, so you have to rebuild, and Expo Go will not work.
# Installnpx expo install expo-paste-input# Native code is involved, so nothing runs until you get through herenpx expo run:iosnpx expo run:android
If you have been growing a Rork-generated app by checking it in preview, that line matters more than it looks. The moment you add paste support, your verification path changes from open the preview to build and install on a device. One check goes from seconds to minutes.
I prefer to make that switch by calendar rather than by feature. If a native module is anywhere on the plan, I move to a development build at the start of the week rather than the moment I need it. The period where you are drifting between both environments is where the mistakes happen. I covered getting onto real hardware in testing on a real iPhone with Rork Companion; this is the decision that comes right after it.
Here is how I weigh it.
Situation
Recommendation
Reasoning
Still iterating on screen structure
Defer paste support
Do not spend iteration speed before the shape is settled
Chat is the core feature and users are asking
Move to a development build now
A missing core feature outweighs iteration speed
This would be your only native module
Safe to move
Fewer native dependencies means more stable rebuilds
You are submitting for review this week
Start after submission
Do not touch the native layer right before a submission
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You'll be able to decide, for your own situation, whether to leave preview-based iteration for a development build before you add paste support
✦You'll be able to write the relocation step that moves temporary paste files somewhere the system cannot reclaim them, heading off the kind of vanished-image report that is nearly impossible to reproduce later
✦You'll be able to hold the iOS sticker path and the Android clipboard path as a fixed order of checks on real hardware instead of a vague intention to test both
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
onPaste returns three shapes, and text and images are inverted
The usage itself is short. Wrap your own TextInput in TextInputWrapper and take onPaste. No prop mirroring, no swapping in someone else's input component.
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': // Text arrives AFTER it has already been inserted. // Calling setText here would duplicate it, so we leave state alone. break; case 'images': // Images arrive with the default paste already suppressed. // If you do not handle them here, nothing happens at all. setAttachments((prev) => [...prev, ...payload.uris]); break; case 'unsupported': // Files, custom UTIs, and so on. Say something instead of failing silently. console.warn('That kind of content cannot be pasted here'); break; } }; return ( <TextInputWrapper onPaste={handlePaste}> <TextInput value={text} onChangeText={setText} placeholder="Try pasting something" multiline /> </TextInputWrapper> );}
It is worth pausing on the asymmetry here, because one callback carries two opposite contracts.
The text paste event fires after the characters have been inserted into the input. It is closer to a notification. The image paste event fires with the default behaviour already prevented, because TextInput cannot render an image and the module has to take over.
If you do not know that, writing setText(payload.value) under case 'text' looks entirely reasonable and duplicates every pasted string. The most readable version of the code is the wrong one, which is the hardest kind of bug to spot in review.
Paths.cache is documented as a place the system may delete
Now the part that cost me the afternoon.
What comes back in payload.uris is a local path beginning with file://. The README says, in one line, that these are temporary files and you should move them if you need persistence. Skimming past that line was my mistake.
A place to store files that can be deleted by the system when the device runs low on storage
Only for the instant you receive them
Paths.document
A place to store files that are safe from being deleted by the system
Yes, if the file has to survive until send
Pasted images land in the first one. Which means that between the moment a user pastes an image and the moment they tap send, the operating system is permitted by design to reclaim that file.
This is not a theoretical worry. In the wallpaper apps I run as an indie developer, downloaded images lived in the cache directory for a while, and reports that a saved wallpaper had disappeared by the next day came in only from users whose devices were nearly full. My own development device has plenty of free space, so no amount of retrying reproduced it. Finding the cause took far longer than fixing it. Since then, the first question I ask about any storage location is whether I would mind if it vanished.
A chat draft is firmly in the mind-very-much category. Drafts sit for hours. Apps get backgrounded overnight. Both are ordinary.
Move it to documents the moment you receive it
There is only one fix: relocate the file inside onPaste, before any asynchronous work happens.
import { Directory, File, Paths } from 'expo-file-system';const ATTACHMENT_DIR = 'pasted';/** * Move freshly pasted temporary URIs somewhere the system will not reclaim. * Returns the relocated files. Anything that fails is excluded, never silently kept. */export async function persistPastedImages(uris: string[]): Promise<File[]> { const dir = new Directory(Paths.document, ATTACHMENT_DIR); // create() defaults to intermediates:false and idempotent:false. // Without these options the second call throws. dir.create({ intermediates: true, idempotent: true }); const saved: File[] = []; for (const uri of uris) { const source = new File(uri); // A temporary file may already be gone. Always check before moving. if (!source.exists) { console.warn(`Pasted image no longer exists: ${uri}`); continue; } // Clipboard images often arrive without an extension. Derive one from the MIME type. 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() takes the destination and rewrites source.uri in place. await source.move(destination); saved.push(source); } catch (error) { console.warn(`Failed to relocate: ${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; }}
Three holes are being closed here.
The first is the default options on dir.create(). Both intermediates and idempotent default to false, so writing it bare works the first time and throws on the second paste. That is the shape of failure testing misses most reliably, because the happy path passes.
The second is filename collision. Temporary files sometimes get short generic names, and RelocationOptions.overwrite defaults to false, so a repeat name throws rather than silently clobbering. A timestamp plus a short random suffix avoids it.
The third is the extension. Images that come through the clipboard frequently have none. Upload an extensionless file and whether it survives depends entirely on how your server sniffs the MIME type. File exposes type, so you can fill the gap locally.
move() does not return a new File. It rewrites the one you called it on
You may have looked twice at saved.push(source) above. We construct destination, and then push source into the array.
That is not a typo. File.move() returns Promise<void>. It does not hand back the relocated file; it rewrites the uri property of the instance you called it on. The documentation says as much: uri is read-only, but it may change as a result of calling methods such as move.
const file = new File(Paths.cache, 'pasted-image.png');console.log(file.uri); // .../Caches/pasted-image.pngawait file.move(new Directory(Paths.document, 'pasted'));console.log(file.uri); // .../Documents/pasted/pasted-image.png <- same instance, new location
If your instincts come from the web File API, this mutation is hard to predict. My first attempt was const moved = await source.move(destination), and I spent a while wondering why moved was undefined. You do not use the return value. You use source after awaiting.
There is a synchronous moveSync() as well. A handful of files at a few hundred kilobytes will not be felt either way, but if you expect users to paste a batch of screenshots at once, the async version is the safer default.
Clean up on send and on discard
The destination is a location the system will not clean. Everything the OS used to sweep for you is now your responsibility.
import { Directory, File, Paths, UploadType } from 'expo-file-system';/** Send attachments, deleting each one as it succeeds */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(); // Non-2xx responses still resolve. Do not treat completion as success. if (result.status >= 200 && result.status < 300) { uploaded.push(file.uri); file.delete(); } else { throw new Error(`Upload failed with status ${result.status}`); } } return uploaded;}/** Delete leftovers when the user throws the draft away */export function discardDraft(files: File[]) { for (const file of files) { if (file.exists) { file.delete(); } }}/** On launch: sweep attachments stranded by a previous crash */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() resolves for completed HTTP responses including non-2xx ones. If you try to catch both network failures and a server returning 413 or 415 with the same try/catch, the second category slips through. You have to read status explicitly.
sweepOrphanedAttachments is meant to run once at launch. When the app is killed, attachments are neither sent nor discarded, and they stay. Individually they are small, but anything that accumulates in a location nothing else cleans will eventually matter. I keep housekeeping like this on the list described in the four acceptance checks I run after the generation loop reports green, because this is exactly the kind of code you write once and never think about again.
Where iOS and Android diverge
Internally the module uses genuinely different mechanisms per platform. Knowing which is which is what lets you order your device testing sensibly.
Aspect
iOS
Android
Interception point
Native paste(_:)
OnReceiveContentListener and ActionMode
Source of the media
UIPasteboard
Clipboard and content APIs
Keyboard stickers
Handled through separate text-input hooks
Arrive through the ordinary clipboard path
Animated GIFs
Preserved
Preserved
Notable side effect
—
The "Can't paste images" toast stops appearing
On device, I always check these four in this order.
On Android, confirm the "Can't paste images" toast is gone. This is the clearest signal that the native side is actually installed, because it changes whether or not your JavaScript is correct. If the toast still appears, stop and check the build.
On iOS, paste a sticker from the keyboard. Stickers travel a different path from clipboard images, so verifying copy-and-paste alone leaves that branch untested. The README notes that stickers are not always exposed the way normal clipboard image data is; since the implementation is split, the testing has to be split too.
Paste an animated GIF and check it still animates. Plenty of implementations in this space flatten stickers into static images, so verify your preview rendering as well as the module.
Paste, background the app, wait several minutes, then send. This is the only way to exercise the relocation code. Cache reclamation rarely triggers on a development device with plenty of free space, so the correctness of the move itself has to be read from the code, but you can at least confirm the send is going out with the relocated URI.
The genuinely hard part of adopting this module was never getting paste to work. That takes half a day at most. The hard part is understanding where the file:// you were handed actually lives, and designing your storage and lifecycle around that answer.
If you want a concrete first step, go looking through your existing code for places where you hold a temporary file URI directly in state. Image pickers, the camera, downloads, and now paste. The same hole tends to be open in more than one place.
I did not think about that distinction myself until a wallpaper app taught me the hard way. If this saves you the same detour, I am glad.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.