App Store Screenshots Without a Phone: Robolectric, a Fake Image Loader and a Headless Browser
By Nesim Razon, independent Android developer · 2026-11-16
My iPhone apps share their screens with the Android versions, written once in Compose. App Store Connect wants screenshots at 1206x2622 pixels, and I work on a Windows laptop with no iPhone to spare for the job and a limited amount of cloud build minutes that I would rather spend on real builds. So I let unit tests draw the screens. This is how it works, what broke, and what these images honestly are.
The idea: let a unit test draw the screen
Robolectric runs Android code on the desktop JVM, and with native graphics mode it can draw Compose to a real bitmap. Roborazzi saves that bitmap as a PNG. So a screenshot becomes a test: set up the screen, click through to the state I want, capture the root node, done. No emulator, no phone, no build service.
One qualifier does the sizing: w402dp-h874dp-xxhdpi. That is a 402 by 874 dp screen at three pixels per dp, which gives exactly 1206x2622 pixels, the size App Store Connect asks for. I checked the output files rather than trusting the arithmetic.
Safe areas by hand
An Android test window has no notch, so the top of every screen would sit under where the Dynamic Island goes. In the test I wrap the app in a box with 47 dp of top padding, which stands for the iPhone safe area. In one app I also added 34 dp at the bottom for the home indicator, because that app has a bottom navigation bar.
It is a rough stand-in, not a measurement. It only has to make the layout look the way it would look on the phone, with nothing hidden under the island.
Network images that never load
Robolectric has no useful network in these tests, so article covers and satellite pictures would show as empty boxes. Coil has a FakeImageLoaderEngine for this: I register real images, saved as test resources, against the URLs or URL patterns the app would request. The screenshot then shows real pictures and not placeholders.
The data gets the same treatment. For the news reader the feed in the test is a copy of the real feed taken from the live site. For the space app the daily data is a copy of the real daily file. I do not invent content for a store listing; I freeze real content.
Live content and endless loops
Some screens show things that change, like the current position of the space station. For those the test passes a fixed sample object, and the screen draws it as if it had just arrived.
That needed a change in the app code. The live screen polled in a loop, and a while(true) loop with a delay inside it can keep the test framework's waitForIdle from ever returning, because the app is never idle. I did not wait to find out whether it would hang; I added a preview parameter up front: when a sample is given, the screen shows it and does not start the loop. It is a small testing seam that also makes the screen easier to preview.
Screens with a WebView
Robolectric does not draw a WebView. The article view and the static pages in my news apps are WebViews, so the screenshot came out with a hole where the page should be.
The workaround has two halves. The test writes the page HTML, built by the same shared function the app uses, plus a screenshot of the screen without the page: just the app bar, which is 111 dp or 333 pixels high. A Python script then renders that HTML in headless Edge inside a 402-pixel-wide iframe at 3x scale and pastes the result under the app bar. The page is drawn by a real browser engine, the bar by Compose, and they meet at a fixed line.
The traps
Each of these cost me an evening or part of one.
- Headless Edge will not go narrower than about 500 px, so the page was cut off on the right. The fix was a wrapper page with a 402 px iframe, then cropping the screenshot.
- Edge can detach from the process, so the screenshot file appears after the command has already returned. The script polls for the file and uses its own user data directory, so it does not collide with my open browser.
- Roborazzi writes RGBA PNGs, and App Store Connect wants RGB without transparency. A small script flattens them before upload.
- A folder in an app repository named screenshots_ios was picked up by my website generator as a folder of newer screenshots. Three web pages silently lost their screenshots until I restricted the search to folder names that start with a digit-versioned pattern.
What these images are, and are not
These are Android renderings sized for an iPhone. Fonts, scrolling feel, system bars and some small controls are not what iOS draws. I accept that for the store listing because the shared UI is the same code, but I do not call them iOS captures.
The actual iPhone behavior of those apps I checked separately, on an iPhone 7 installed through TestFlight. The screenshots show the screens; the real device told me whether the screens work.
Frequently asked questions
Does this produce real iOS screenshots?
No. They are Android renderings of the shared Compose UI, sized to 1206x2622. Real iPhone behavior was checked separately on a device through TestFlight.
Why is the size exactly 1206x2622?
The test uses a 402 by 874 dp window at xxhdpi, which is three pixels per dp, so the output is 1206 by 2622 pixels.
Why not take screenshots on a real iPhone or a simulator?
I have no spare device for it and no Mac, and I want to keep my cloud build minutes for real builds. Unit tests run on my own laptop for free and can be repeated at any time.
Why are WebView screens handled separately?
Robolectric does not draw WebViews, so the page HTML is rendered in headless Edge and pasted under a Compose-drawn app bar.
More from the blog
- Why My App Store Link Returned 404 and the Page Showed 2018 Screenshots
- App Store Review Guideline 2.1 "Information Needed": What I Sent
- One QR Code for Two App Stores: A Tiny Redirect Page
- Rewriting for Kotlin Multiplatform Without Changing Behavior: Old Code as a Test Oracle
- A Serverless Remote List for "More Games": One JSON File on GitHub Pages
- Debugging an iPhone From Windows Over USB: Screenshots and Logs With Python