●BUILD — Rork Max runs real Macs in the cloud loaded with Xcode and the iOS SDK, writing SwiftUI, compiling, reading the errors and building again. That loop, not the code generation, is what lifts the output●NATIVE — What comes out is pure Swift and SwiftUI, not React Native. Reaching AR, Metal graphics and widgets that React Native cannot touch is the real gap between this and other builders●PLATFORMS — Coverage spans iPhone, iPad, Apple Watch, Apple TV and Vision Pro, plus iMessage. Worth a look if you want to start from a watch app or an extension rather than a phone screen●COMPANION — The Rork Companion app lets you check a generated build on a real iPhone without a paid Apple Developer account, lowering the bar for trying a first project end to end●PRICING — Free to start, paid plans from $25 a month, and Rork Max on the $200 Max plan. Worth working out up front how many projects it takes to earn that back●DEADLINE — From August 31, 2026, Google Play requires target API level 36 or higher for new apps and updates alike. Ten days out, and the targetSdkVersion of what you generate is yours to verify●BUILD — Rork Max runs real Macs in the cloud loaded with Xcode and the iOS SDK, writing SwiftUI, compiling, reading the errors and building again. That loop, not the code generation, is what lifts the output●NATIVE — What comes out is pure Swift and SwiftUI, not React Native. Reaching AR, Metal graphics and widgets that React Native cannot touch is the real gap between this and other builders●PLATFORMS — Coverage spans iPhone, iPad, Apple Watch, Apple TV and Vision Pro, plus iMessage. Worth a look if you want to start from a watch app or an extension rather than a phone screen●COMPANION — The Rork Companion app lets you check a generated build on a real iPhone without a paid Apple Developer account, lowering the bar for trying a first project end to end●PRICING — Free to start, paid plans from $25 a month, and Rork Max on the $200 Max plan. Worth working out up front how many projects it takes to earn that back●DEADLINE — From August 31, 2026, Google Play requires target API level 36 or higher for new apps and updates alike. Ten days out, and the targetSdkVersion of what you generate is yours to verify
The three places I had to fix before a Rork project actually targeted API level 36
My app.json said targetSdkVersion 36. The value my build actually read was 35. Here is the script that reports the effective value, and how I split my apps between raising, leaving alone, and requesting an extension.
Checking thirteen days before the deadline turned out to be the right call.
Google Play starts requiring API level 36 on August 31, 2026. My Android projects already had targetSdkVersion: 36 in app.json from an earlier pass. The declaration was done, or so I assumed.
The value my build actually read was 35.
I noticed the gap while counting days left, not while checking a submission. Had I found it on submission day, I doubt I would have made it into the review queue in time. What follows is where that gap comes from, what I changed, and which apps I deliberately chose not to touch.
What changes on August 31, and what does not
The primary sources first. Skipping this step leads to fixing things that did not need fixing.
Case
Requirement from August 31, 2026
New app submissions and app updates
Must target Android 16 (API level 36) or higher
Wear OS / Android Automotive OS
Android 15 (API level 35) or higher
Android TV / Android XR
Android 14 (API level 34) or higher
Existing apps you are not updating
At API level 35 or higher, they stay available to new users on devices running newer OS versions
That last row is where my decision turned.
An app you are not updating does not disappear on August 31. If it already targets API level 35, it keeps reaching new users on newer devices. What stops is the ability to submit an update. Reading this as "every app must move to 36 or distribution stops" is how you spend thirteen days on the wrong work.
There is also an extension. If you cannot make August 31, the details page of the warning on the Policy status page in Play Console opens an extension form that keeps you distributing to all Google Play users until November 1, 2026. The extension is not automatic, though. You have to file it before the deadline.
targetSdkVersion is not decided in one place
In a project generated by Rork or Expo, there are three places that can decide targetSdkVersion, as far as I could count.
The expo-build-properties plugin block in app.json
android.targetSdkVersion in android/gradle.properties
The fallback default in the ext block of android/build.gradle
The catch is when (1) reaches (2). expo-build-properties is a config plugin that runs while npx expo prebuild generates the native directories, and the official documentation states plainly that it cannot be used in projects that do not run prebuild, meaning bare projects.
So once android/ is committed to your repository, editing app.json changes nothing until a prebuild runs. And the default in android/build.gradle is frozen at whatever SDK version the project was generated against.
In my case, I had edited app.json while reading the Expo docs, and some of those projects had already had their native directories generated. The declaration moved forward; the value the build reads stayed put. The build succeeds. Signing succeeds. You get an AAB. Nothing errors.
The more you ship generator output as-is, the more likely you are to miss this. Nothing ever gives you a reason to stop and look.
✦
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
✦Judge targetSdkVersion by the value your build actually reads, not by what app.json declares
✦Sweep every project you own with one command and know whether you will make the deadline, instead of finding out when a submission gets rejected
✦Decide per app whether to raise, leave alone, or file for an extension, using the app's own release situation
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.
I needed the value the build actually reads, not the declaration, so I wrote the resolution out. Tracing three files by eye stops being reliable the moment you have more than a couple of projects.
#!/usr/bin/env node// audit-target-sdk.mjs// Reports the value the build actually reads, not what app.json declares.import { readFileSync, existsSync } from "node:fs";import { join } from "node:path";const REQUIRED = 36; // What Google Play requires from 2026-08-31function readJson(p) { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }}// (1) What expo-build-properties declares in app.jsonfunction declaredInConfig(root) { const cfg = readJson(join(root, "app.json")) ?? readJson(join(root, "app.config.json")); const plugins = cfg?.expo?.plugins ?? []; for (const p of plugins) { if (Array.isArray(p) && p[0] === "expo-build-properties") { return p[1]?.android?.targetSdkVersion ?? null; } } return null;}// (2) What android/gradle.properties actually holdsfunction inGradleProperties(root) { const f = join(root, "android", "gradle.properties"); if (!existsSync(f)) return null; const m = readFileSync(f, "utf8").match(/^android\.targetSdkVersion\s*=\s*(\d+)/m); return m ? Number(m[1]) : null;}// (3) The fallback default in android/build.gradlefunction fallbackInBuildGradle(root) { const f = join(root, "android", "build.gradle"); if (!existsSync(f)) return null; const m = readFileSync(f, "utf8") .match(/targetSdkVersion\s*=\s*Integer\.parseInt\(\s*findProperty\('android\.targetSdkVersion'\)\s*\?:\s*'(\d+)'\s*\)/); return m ? Number(m[1]) : null;}const roots = process.argv.slice(2);let failed = 0;for (const root of roots) { const hasNativeDir = existsSync(join(root, "android")); const declared = declaredInConfig(root); const props = inGradleProperties(root); const fallback = fallbackInBuildGradle(root); // When android/ is committed, prebuild does not run by default, so the // app.json declaration never reaches the build. Resolution order is // gradle.properties first, then the build.gradle default. const effective = hasNativeDir ? (props ?? fallback) : declared; const source = hasNativeDir ? (props != null ? "android/gradle.properties" : "android/build.gradle (default)") : "app.json (applied at prebuild)"; const ok = effective != null && effective >= REQUIRED; if (!ok) failed++; console.log(`${root}`); console.log(` declared in app.json : ${declared ?? "(none)"}`); console.log(` android/ present : ${hasNativeDir ? "yes (bare-like, prebuild does not run by default)" : "no (managed)"}`); console.log(` effective target SDK : ${effective ?? "(unknown)"} <- ${source}`); console.log(` verdict : ${ok ? "OK" : `FAIL (needs ${REQUIRED}+)`}`); console.log("");}console.log(`audited ${roots.length} / failed ${failed}`);process.exit(failed > 0 ? 1 : 0);
The order matters: gradle.properties first, then build.gradle. That mirrors how Gradle's findProperty resolves. The right side of ?: in build.gradle is only a fallback for when the property is absent. Read it the other way around and you will conclude that writing 36 into build.gradle is enough.
Branching on whether android/ exists is the other key point. Once that directory is there, the project is no longer one that runs prebuild on every build. The app.json value gets printed for reference only. It never feeds the verdict.
Here is the run.
$ node audit-target-sdk.mjs managed bare
managed
declared in app.json : 36
android/ present : yes (bare-like, prebuild does not run by default)
effective target SDK : 36 <- android/gradle.properties
verdict : OK
bare
declared in app.json : 36
android/ present : yes (bare-like, prebuild does not run by default)
effective target SDK : 35 <- android/build.gradle (default)
verdict : FAIL (needs 36+)
audited 2 / failed 1
Both declare 36 in app.json. The effective values split into 36 and 35. Grepping app.json to confirm your fleet would show you none of this.
The exit code carries the failure count, so it drops in front of a release script or into CI. I put it as the first stage of my pre-submission check.
The three places I changed
The edits themselves are unremarkable. That is exactly why noticing them is the whole job.
Location
Before
After
android/gradle.properties
The line did not exist
Added android.targetSdkVersion=36 and android.compileSdkVersion=36
ext block in android/build.gradle
Fallback was '35'
Raised to '36' as insurance if the property is ever removed
expo-build-properties in app.json
Only targetSdkVersion: 36
Added compileSdkVersion and buildToolsVersion: "36.0.0"
A note on the third row. Raise targetSdkVersion while leaving compileSdkVersion behind and compilation breaks the moment you reference an API introduced in Android 16. Raise only compileSdkVersion and you have not satisfied Play at all.
Review looks at targetSdkVersion. The compiler looks at compileSdkVersion. They serve different purposes, so it is safer never to leave one raised without the other. I pinned buildToolsVersion alongside them so the resolved version does not drift when the project is built on a different machine later.
My final confirmation came from the App bundle explorer in Play Console after uploading the AAB. The target API level shown there is the value Play actually read. If your source-side audit passes but that screen still says 35, it is not fixed.
How I split raising, leaving alone, and filing an extension
This is the part I actually spent time on. Raising everything to 36 was not the answer.
What I publish on Google Play is a set of wallpaper and calming apps, and their update cadence varies a lot. Some get new image assets almost monthly. Others have settled feature-wise and have not been touched in over half a year. Ignore that difference, raise everything at once, and you have committed to finishing device testing on all of them within thirteen days. That plan breaks.
Here is how I split them.
Situation
What I chose
Why
An update is planned before the deadline
Raise to 36 and test on device
Submitting the update itself requires 36. There is no way around it.
No update planned, effective value already 35 or higher
Nothing this cycle
Distribution to new users continues. Touching it in a hurry only adds regression risk.
Update wanted, but Android 16 behavior checks will not finish
File the extension, keep working on the raise
It buys until November 1. The filing itself has to land before the deadline.
Deciding to leave the middle row alone was what actually saved the schedule.
My first instinct on seeing the word "deadline" was to do all of it. Only after drawing the table did I see that the time budgeted for apps with no planned update was entirely unnecessary. The deadline is not a deadline for every app. It is a deadline for apps you intend to update.
On the extension, I lean toward filing early when filing is available at all. If you file and then finish the raise in time, the filing is simply unused and costs you nothing. If you do not finish and August 31 passes, the window itself closes. One path has no downside; the other is unrecoverable. When the asymmetry looks like that, I move early.
What I checked on a real device afterward
Raising targetSdkVersion is a statement to the OS that it may run your app under the newer behaviors. A build passing and behavior staying the same are two different things.
Three things I checked first, on hardware:
Window insets. When edge-to-edge handling shifts, content slides under the navigation bar. Wallpaper apps show previews at near-full-screen, so this was my first stop. I hit the same class of problem when moving to API 35 (Fixing Layout Bleed on Android 15 (API 35) in Rork Apps).
Saving images and media access. Storage permissions are the area most likely to shift with a target level change. I confirmed saving a wallpaper to the device once per app, on hardware.
Notifications. I checked when the permission prompt appears and how a re-request behaves after a denial.
I did not settle for the emulator here because permission behavior depends on both the OS version and the manufacturer's implementation. I do not have many devices on hand, but one device is still meaningfully different from zero.
Regenerating android/ is another way to line everything up. If you go that route, be aware that prebuild erases hand-written native edits (Find the native edits expo prebuild will erase before you upgrade to SDK 57). With the deadline close, I chose the smaller move and edited the properties instead.
If you do one thing now
Open android/gradle.properties before you open app.json.
If there is no android.targetSdkVersion line in it, your build is running on the build.gradle default no matter what app.json says. I learned that thirteen days out.
When this audit works, nothing happens. How much that is worth probably depends on whether you have ever had a submission bounce. I have misread deadline rules more than once, and until I drew the table this time, I was still planning to touch every app I own.
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.