Getting a Xamarin Android Build Past the Play Target API Requirement

Google Play enforces a minimum targetSdkVersion for app updates and raises it on an annual schedule. If your app does not meet the current requirement, the Play Console rejects the upload — not the review, the upload. You cannot ship a bug fix, and you cannot ship a security patch.

On a maintained app this is a routine annual chore. On a Xamarin app that has not been touched in three years it is a project, because you are usually crossing several API levels at once and meeting all of their behaviour changes in one release.

Here is how to find out what you are facing, in the order that matters.

Step 1: establish where you actually are

Check the target framework and target SDK in the Android head project. In a Xamarin.Android project these live in the .csproj and the manifest, and they disagree more often than you would like — a manifest targetSdkVersion that was set by hand and a build property that was updated later are a classic mismatch. The value that counts at upload is the one in the merged manifest of the artifact you upload, so check that rather than the source.

Then check the current Play requirement in the Play Console itself. It is stated there for your app, with the deadline, and it is authoritative in a way that a blog post from last year is not.

Step 2: know that you cross every level in between

This is the part teams underestimate. Going from an old target level to a current one means your app is now subject to the behaviour changes introduced at every level in between, all at once, and they apply as soon as the target level says they do — regardless of what device the app runs on.

The categories that break Xamarin apps most often:

Storage. Scoped storage changed how apps read and write shared files, and apps that wrote to arbitrary external paths — exported reports, cached attachments, photo capture — stop being able to. Line-of-business apps do this constantly.

Background execution. Background service starts, location in the background, and long-running work all became more restricted. An app that syncs in the background, tracks a delivery route, or keeps a socket open is affected.

Foreground services. Newer levels require a declared foreground service type and require you to justify certain types at upload. An app that uses a foreground service for sync or location needs both the manifest declaration and a listing-level answer.

Permissions. Runtime permission behaviour changed repeatedly: one-time grants, automatic revocation for unused apps, and a separate notification permission that now has to be requested rather than assumed.

Exported components. Activities, services and receivers with intent filters must explicitly declare android:exported. Miss one and the app will not install on newer devices — this one fails hard and early, which is mercifully easy to spot.

PendingIntent mutability. Every PendingIntent must specify mutable or immutable. Notification code and alarm code are where this lands.

Step 3: find out what your app actually hits

Raise the target level in a branch and run the app on a device running a matching OS version. Not an emulator at default settings — a device with real data, a real account, and a real network. Then walk the flows that touch the categories above: anything that writes a file, anything that runs while the app is backgrounded, anything that posts a notification, anything that asks for a permission.

Two things help:

  • Turn on strict behaviour checks. Android's compatibility framework lets you toggle individual behaviour changes per app from adb, which lets you isolate which change is responsible for a failure instead of guessing.
  • Watch the log during a full run-through. Deprecation and compatibility warnings frequently name the exact API and the level that changed it, and they appear well before the failure does.

Step 4: the dependency problem

Raising the target level often forces dependency updates — an old support library, a binding against a native SDK compiled for an older level, a package that calls an API removed in a later level. This is where the Play requirement and the dependency inventory meet, and it is frequently the longest pole.

If a binding library has no version compatible with the target level, you have three options: rebuild the binding against a current native SDK, replace the functionality, or remove the feature. All three are real answers. Which one is correct depends on the feature, and it should be a written decision rather than a discovery.

Step 5: when the toolchain cannot get there

Sometimes the answer is that this Xamarin project cannot be built against the required level at all, because the toolchain that could do it is no longer supported. At that point the shortest compliant path is usually not more work on the Xamarin project. It is a .NET MAUI project hosting the existing screens through the compatibility layer — which satisfies the requirement, ships, and counts as the first slice of the migration instead of throwaway work.

That decision needs to be made deliberately and early, because the two paths diverge in week one. It is the first question a release rescue answers.

Step 6: do not batch it with a feature release

Ship the target-level raise on its own, with no new functionality attached. When something breaks in the field — and with a multi-level jump something usually does — you want the release notes to contain exactly one change.

And then it happens again next year

The requirement moves annually and the schedule is published, which means this is a date you can put in a calendar rather than a surprise. The reason it feels like a surprise is that nobody owns it. That is the entire argument for maintenance being a standing arrangement rather than something reconstructed from scratch each time the Play Console sends an email.