●DEADLINE — Google Play's Android 16 (API level 36) requirement lands tomorrow, August 31. One day left, and it covers new apps and updates to existing ones alike●DECISION — An extension cannot be filed once the deadline passes. So the real cutoff today is not the build itself but deciding whether to make it or to file instead●VISIBILITY — Apps you no longer update are not exempt. Anything still below API level 35 stops appearing for new users on newer Android devices●PITFALL — When a dependency has not caught up to the new API level, the local build can pass while the store pre-check stops you. Reading your own code will not surface that●EXPO — expo@57.0.17 moved React Native to 0.86.3. A pattern is emerging of small non-breaking patches between major SDK releases, which lowers the cost of keeping current●CHOICE — Shipping to Android too points at Rork proper and its React Native output; Apple-specific capabilities at the center of the spec point at Rork Max and Swift. It is a question of what you ship●DEADLINE — Google Play's Android 16 (API level 36) requirement lands tomorrow, August 31. One day left, and it covers new apps and updates to existing ones alike●DECISION — An extension cannot be filed once the deadline passes. So the real cutoff today is not the build itself but deciding whether to make it or to file instead●VISIBILITY — Apps you no longer update are not exempt. Anything still below API level 35 stops appearing for new users on newer Android devices●PITFALL — When a dependency has not caught up to the new API level, the local build can pass while the store pre-check stops you. Reading your own code will not surface that●EXPO — expo@57.0.17 moved React Native to 0.86.3. A pattern is emerging of small non-breaking patches between major SDK releases, which lowers the cost of keeping current●CHOICE — Shipping to Android too points at Rork proper and its React Native output; Apple-specific capabilities at the center of the spec point at Rork Max and Swift. It is a question of what you ship
After Bumping targetSdk to 36, Which Part of Your Dependencies Should You Actually Read
I opened six real dependency AARs and counted what they contribute to my manifest. Not one declares targetSdkVersion, and twelve permissions arrive that I never wrote. Here is what to check before you submit.
Bumping targetSdkVersion to 36 and watching the build go green is the easy part. I got stuck one step later. I kept reading that a dependency which has not kept up with the new API level can pass a local build and still get stopped by the store's pre-submission checks — and I realised I had no idea what, specifically, I was supposed to look at on the dependency side.
When you carry several apps at once as an indie developer, the day before a deadline is not the day to read six changelogs. What I wanted was something mechanical: a list of what my dependencies are writing into my own manifest, produced in a couple of minutes. This is the record of getting that list.
Here is the finding that reframed the whole exercise. Dependency manifests do not declare targetSdkVersion. Across the six AARs I actually opened, not a single one did. "Audit the targetSdk of your dependencies" turns out to be a search for a value that is not there.
I opened six dependency manifests and counted
I pulled the AARs straight from Google's Maven repository — the libraries that show up in almost any Expo-based app — and read the AndroidManifest.xml inside each one. The manifest in an AAR is plain text, not binary XML, so no Android SDK and no Gradle are required. Unzip and read.
Here is what the uses-sdk line contained in each case.
Six out of six carry minSdkVersion and nothing else. That is not laziness or staleness on the library authors' part. It is how the platform works.
A library's targetSdkVersion has no effect on your artifact. Android applies behaviour changes per process, based on the application's targetSdkVersion. Library code runs inside that same process, so the moment your app declares 36, the library's code starts running under the rules of 36 as well. There is no mechanism by which a library gets to say "I am still a 34 library."
That reframes the actual risk. Raising your value does not leave outdated libraries alone — it drags outdated library code into the new rules. And the library's own declarations tell you nothing about whether its code is ready for that. Since the thing I wanted to inspect does not exist, I had to change what I was inspecting.
What does arrive: permissions, services, queries, and properties
targetSdkVersion never crosses the boundary. Plenty of other things do. Across the same six dependencies, counting unique entries, twelve permissions land in my merged manifest. I wrote none of them.
Defines and uses DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION, prefixed with your applicationId
media3-session 1.4.1
Nothing beyond uses-sdk
One of the six contributes nothing at all. That matters practically: you do not have to suspect every dependency, only the ones that actually write something.
The entries I had not been tracking were queries and property. A queries block declares package visibility — which other apps you are allowed to ask about. The property element here points at an AdServices configuration resource. Neither shows up if you are only skimming a permission list. I had been shipping an app with <property android:name="android.adservices.AD_SERVICES_CONFIG"> in its manifest without having thought about it once. If you monetise with AdMob, that entry is already in your Google Play build too.
✦
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 will be able to list exactly what every dependency contributes to your manifest, without installing the Android SDK
✦You will be able to spot the places where two dependencies cancel each other out, before the build reaches the store
✦You will be able to separate what a static manifest check can prove from what only a device run can confirm, and stop mixing the two
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.
This is the tool as I ran it. It covers both AARs and the raw android/src/main/AndroidManifest.xml files that Expo modules ship. In a Rork or Expo project the dependency surface is split between source under node_modules and AARs in the Gradle cache, so scanning only one of them leaves a gap.
#!/usr/bin/env python3"""List what each dependency contributes to your AndroidManifest, no Android SDK required.Usage: python3 manifest_contributors.py node_modules ~/.gradle/caches/modules-2Prints, per dependency: uses-sdk, added permissions, removed permissions,services (with or without foregroundServiceType), queries, and properties."""import sysimport osimport zipfilefrom xml.etree import ElementTree as ETAND = "{http://schemas.android.com/apk/res/android}"TOOLS = "{http://schemas.android.com/tools}"def read_manifests(roots): """Yield (label, xml_text). AARs are zips; Expo modules ship plain XML.""" for root in roots: for dirpath, dirnames, filenames in os.walk(root): # Build output contains extracted copies of the same manifests. # Skipping it is what keeps every library from appearing three times. dirnames[:] = [d for d in dirnames if d not in ("build", ".git")] for fn in filenames: p = os.path.join(dirpath, fn) if fn.endswith(".aar"): try: with zipfile.ZipFile(p) as z: yield os.path.basename(p), z.read( "AndroidManifest.xml" ).decode("utf-8", "replace") except (zipfile.BadZipFile, KeyError, OSError): # AARs without a manifest, and broken zips, are skipped silently continue elif fn == "AndroidManifest.xml" and os.sep + "src" + os.sep in p: try: with open(p, "r", encoding="utf-8", errors="replace") as f: yield os.path.relpath(p, root), f.read() except OSError: continuedef analyze(name, xml): try: root = ET.fromstring(xml) except ET.ParseError: return None rec = { "name": name, "min_sdk": None, "target_sdk": None, "permissions": [], "removed": [], "services": [], "queries": 0, "properties": [], } for u in root.findall("uses-sdk"): rec["min_sdk"] = u.get(AND + "minSdkVersion") rec["target_sdk"] = u.get(AND + "targetSdkVersion") # in practice, always None perms = root.findall("uses-permission") + root.findall("uses-permission-sdk-23") for p in perms: nm = p.get(AND + "name") # tools:node="remove" declares a deletion, not an addition. Count it separately. if p.get(TOOLS + "node") == "remove": rec["removed"].append(nm) else: rec["permissions"].append(nm) rec["queries"] = len(root.findall("queries")) app = root.find("application") if app is not None: for s in app.findall("service"): rec["services"].append((s.get(AND + "name"), s.get(AND + "foregroundServiceType"))) for pr in app.findall("property"): rec["properties"].append(pr.get(AND + "name")) return recdef main(roots): seen_perm = {} rows = [] for name, xml in read_manifests(roots): rec = analyze(name, xml) if not rec: print(" parse error: " + name) continue # Dependencies that contribute nothing are not worth a line of output if not any([rec["permissions"], rec["removed"], rec["services"], rec["queries"], rec["properties"]]): continue rows.append(rec) for perm in rec["permissions"]: seen_perm.setdefault(perm, []).append(rec["name"]) for r in sorted(rows, key=lambda x: x["name"]): print("== " + r["name"]) print(" uses-sdk: min=" + str(r["min_sdk"]) + " target=" + str(r["target_sdk"])) for perm in r["permissions"]: print(" + permission " + str(perm)) for perm in r["removed"]: print(" - permission " + str(perm) + " (tools:node=remove)") for nm, fgs in r["services"]: print(" * service " + str(nm) + " foregroundServiceType=" + (fgs or "none")) if r["queries"]: print(" * queries blocks: " + str(r["queries"]) + " (package visibility)") for prop in r["properties"]: print(" * property " + str(prop)) print("") print("--- summary ---") print("dependencies analysed: " + str(len(rows))) print("permissions injected (deduplicated): " + str(len(seen_perm))) multi = {k: v for k, v in seen_perm.items() if len(v) > 1} for k, v in sorted(multi.items()): print(" declared by several: " + k + " <- " + ", ".join(v)) no_target = [r["name"] for r in rows if r["target_sdk"] is None] print("dependencies with no targetSdkVersion: " + str(len(no_target)) + " / " + str(len(rows)))if __name__ == "__main__": if len(sys.argv) < 2: print(__doc__) sys.exit(2) main(sys.argv[1:])
Two lines in there exist because the first version was wrong.
Excluding build from the walk came after my first run listed several libraries two and three times. Gradle keeps extracted manifests among its intermediates, and walking into them double-counts everything. Dropping generated directories from the scan changes the readability of the output completely.
Separating tools:node="remove" from the additions came after a subtler mistake. My first version counted every uses-permission element as a permission being added — including elements that declare a removal. As the next section shows, that particular mix-up inverts the conclusion.
What the run produced
Here is the output with the six AARs in a single directory, trimmed to the interesting parts.
More permissions are declared by several libraries at once than I expected. That is not a problem in itself, but it becomes useful the moment you remove a library. ACCESS_NETWORK_STATE has four declarants, so dropping one changes nothing. POST_NOTIFICATIONS has exactly one — firebase-messaging — so removing that library takes the permission with it. The duplication table answers "if I drop this dependency, what changes for the user" before you try it.
WorkManager adds RECEIVE_BOOT_COMPLETED so it can rebuild its job schedule after a restart. The AdMob SDK declares a removal of the same permission. In an app that ships both, those two declarations push on the same element in opposite directions.
Manifest merging is not an additive-only operation. A dependency can delete declarations — yours, or another dependency's. Build a list of "permissions my dependencies add" without accounting for that, and your list will disagree with the artifact you ship. My first version of the script made exactly this error, which is how I learned it.
Which declaration survives depends on merge priority: your application manifest sits at the top, and library manifests follow in dependency order. That is not something a static inventory can settle, so I stopped trying to reason it out and went to read the merged result instead. Two files answer it.
The final form that actually goes into the APK or AAB
The point of the inventory is to give you an aiming point for those two files. Reading several hundred lines of merged manifest from the top is not realistic under deadline. Checking one specific permission line takes a minute. Inventory first, then the merged result — doing it the other way round burns the afternoon.
Separate out what the manifest cannot tell you
The other place my expectation broke was foregroundServiceType.
Android 14 and later require a type declaration on foreground services, so I assumed the library manifests would carry them. They do not. work-runtime's SystemForegroundService — a foreground service by name and by function — declares no type at all. I assumed I was looking at a stale version, so I fetched 2.10.0 and 2.10.1 as well.
work-runtime version
foregroundServiceType on SystemForegroundService
2.9.1
not declared
2.10.0
not declared
2.10.1
not declared
This is design, not lag. WorkManager uses the type you hand it in ForegroundInfo at runtime. The type is chosen by your code, not by the library, so there is nothing for the library manifest to declare.
The practical consequence is worth stating plainly: foreground service type compliance cannot be determined from a static manifest check. A "none" in the inventory is not evidence of a violation, and a clean inventory is not evidence of safety. That item belongs to a different workflow — launch the job on a device and confirm what your code passes to ForegroundInfo.
Under time pressure this separation earns its keep. Keeping statically-checkable items and runtime-only items in the same list means you end up half-finishing both.
What I decided from the output, the day before the deadline
I sorted the inventory into three buckets, using three questions: does it block submission, is it visible to users, and can I finish verifying it today.
Bucket
What landed there
Action today
Check before submitting
Permissions where two dependencies conflict; queries and property entries I did not write
Open the merged manifest and read only those lines
Can wait until after
Permissions with a single declarant; plain duplicates
Save the inventory and diff it at the next library upgrade
Not a static check
foregroundServiceType and anything decided at runtime
Exercise the feature once on a device; do not judge it from the manifest
queries and property went into the top bucket because they connect directly to store declarations. Package visibility and the AdServices configuration both arrived without my writing them, and I am still the one who gets asked about them. Being able to explain why an entry is present matters more, and sooner, than the value itself.
The closer a deadline gets, the more the result depends on shrinking what you check rather than expanding it. In this case, "verify the targetSdk of each dependency" was never a task that could succeed — the value does not exist. Trading that time for finding two conflicting lines was the return on the afternoon.
If you take one step from this, run the script against your own node_modules and Gradle cache and commit the output to your repository. The next time you upgrade a library, a diff tells you what appeared and what vanished. The inventory is useful once; the delta between inventories stays useful.
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.