A week ago I tried to install one of my favourite childhood games, “Army of Darkness Defense” from 2016, on my Android phone for the nostalgia. It was no longer on the Play Store, so I found the APK on the web. It refused to install:
INSTALL_FAILED_DEPRECATED_SDK_VERSION:
App package must target at least SDK version 24, but found 23
Since Android 14 the OS refuses apps built for ancient API levels, because malware historically targeted low targetSdkVersion to opt out of the runtime permission model. The app still works. The platform just stopped accepting it.
That is the same situation a lot of enterprise software is in. The software works, the vendor is gone or the upgrade costs a fortune, and the only thing standing between it and a supported machine is a compatibility gate that nobody dares open by hand. Usually the answer is a rewrite, a paid migration project, or a VM kept alive for another decade.
I’d been watching people port PS5 games to PC with LLMs, and that was the thing that made me stop and think: the model wasn’t doing anything clever, it was just reading a lot of tedious format documentation and carefully getting details right. That is exactly the profile of a legacy migration. a non-creative Boring task which an Engineer must do here and there.
So I set up an experiment. I have no Android development knowledge at all. I gave the APK to an agent (Deepseek Flash v4.1 in the PI harness) and asked it to make the game install.
A note on whose work this is. I didn’t solve an Android problem, I couldn’t have. The reasoning below is the agent’s, the decisions were its, and the commands are the ones it ran. My contribution was setting up the experiment, reading what it did, and occasionally stopping it from going down the wrong path.
The wrong instinct: newest is best #
first thing which comes to mind is to bump the SDK to 35 (latest). That is also how you turn a working game into a crash loop, it doesn’t work
targetSdk is not a “how new am I” label, it’s a contract. Every value above 24 switches on another behavioural rule the app was never written against: FileProvider requirements at 24, background service restrictions at 26, cleartext HTTP blocked at 28, package visibility at 30. A 2016 game engine that loads assets from local paths and phones home to dead ad networks breaks in several of those.
The right question is not “what is newest” but “what is the lowest SDK that clears the gate”. That is a table, not a guess — and it plateaus at 24:
| Android | API | minimum targetSdk to install |
|---|---|---|
| 14 | 34 | 23 |
| 15 | 35 | 24 |
| 16 | 36 | 24 |
| 17 | 37 | 24 |
So 23 → 24 is the smallest change that clears every current and announced release. This is the part that generalises to enterprise migrations: the goal is the minimum viable compatibility, not the newest everything.
Check for anti-tamper first, not after #
If the app verifies its own signature, re-signing it bricks it, and you only find out once it’s already broken. The agent checked before touching anything:
strings -a classes.dex | grep -E "GET_SIGNATURES|getPackageInfo|checkSignatures"
That hits, which looks alarming. So it asked which class calls them — grepping the 2049-class inventory for PackageManager, integrity or tamper returned nothing. The signature APIs were only reachable through bundled ad and IAP SDKs. Checking native code told the same story: the getSignature string sits next to in-app-purchase receipt parsing, not APK integrity. Verdict: safe to re-sign.
One byte, not a rebuild #
The obvious route is apktool d / apktool b, which re-encodes the whole binary manifest — string pool rebuilt, attributes reordered, resources re-serialised. For a one-number change that is a huge diff and a fresh chance to break something.
Instead: a width-preserving in-place edit. targetSdkVersion is a 4-byte little-endian integer, and 23 and 24 occupy exactly the same space, so nothing else in the file has to move.
The manifest inside an APK is chunked binary XML, not text. Two details cost the agent its first two attempts — attribute offsets are relative to attributeStart, not headerSize, and the typed value sits at +16 inside the attribute:
a = node_off + 16 + attr_start + i * attr_size # correct, not node_off + headerSize
name = strings[name_index] # → "targetSdkVersion"
vtype = buf[a + 15] # 0x10 == TYPE_INT_DEC
vdata = u32(buf, a + 16) # → 23
Then the entire change:
struct.pack_into("<I", buf, attr_offset + 16, 24)
Proving it is the only change #
This is my favourite part, because it is what makes the claim checkable rather than plausible:
a = zipfile.ZipFile(src).read('AndroidManifest.xml')
b = zipfile.ZipFile(dst).read('AndroidManifest.xml')
print(len(a) == len(b)) # True, 13196
print([(i, hex(a[i]), hex(b[i])) for i in range(len(a)) if a[i] != b[i]])
One byte out of 13,196. The equal-length check is what proves nothing else was silently re-encoded.
Static checks are not proof #
aapt2, apksigner verify and zipalign -c all pass, and they still don’t prove the OS will accept the file. So it booted a real Android 15 image and did the A/B:
$ adb install -r original.apk
Failure [INSTALL_FAILED_DEPRECATED_SDK_VERSION: ... but found 23]
$ adb install -r mod-target24.apk
Success
Then confirmed it wasn’t just installed but running: primaryCpuAbi=armeabi-v7a on an arm64 image, both native libraries loaded, zero FATAL EXCEPTION or ANR lines. It boots to the title screen and plays.
The part the patch can’t fix #
The game runs, but only on phones that still ship the 32-bit ARM ABI. The APK is armeabi-v7a only: there is no arm64 build of libgame.so and there never will be, because that library came out of a 2016 studio that no longer exists. That is the limit which the LLM can’t solve.
On the emulator it worked because the system image still carries the 32-bit linker:
- Most phones still run 32-bit app code today, because Android carried the ABI forward for compatibility.
- Newer flagships with 64-bit-only kernels dropped it entirely. There is no flag, no setting and no patch that brings it back — the CPU simply won’t execute the instructions.
- Google has been tightening this from the other side too, pushing 64-bit requirements onto the Play Store.
So the honest summary of the result is: the one-byte fix restores the app on hardware that still supports its ABI, and does nothing for hardware that doesn’t. I made a 2016 app installable again on an Android 15 emulator, not on every Android 15 phone.
Fixing that properly means rebuilding the engine from source, or shipping a 32-bit translation layer, and neither is a manifest edit. This is the same wall as the last one that stops a lot of legacy migrations: the cheap, verifiable, one-byte problem and the expensive architectural problem look like they’re in the same category, and they aren’t. An agent can find the first in an evening. The second is a rewrite, or a decade of virtualisation, which is exactly the outcome I was arguing against at the top.
My Take #
The patch itself is one byte, and an experienced Android developer would have found it faster than the agent did. The interesting part is everything around it: reading the platform’s install gate as a contract instead of a version number, checking for anti-tamper before modifying instead of after, choosing a width-preserving edit over a rebuild, and verifying against a real device rather than trusting static tools. None of that is novel expertise. All of it is tedious, and all of it is checkable.
That is why I think legacy modernisation is a genuinely good fit for these tools, better than a lot of the tasks they get pointed at:
- The rules are documented and stable. The Android install gate is a published table. So is most of what blocks old enterprise software: TLS versions, deprecated syscalls, removed library APIs, 32-bit-only binaries, unsupported runtime versions.
- The work is boring in an expensive way. Nobody’s career is made by bumping a manifest field. It’s the kind of task that sits in a backlog for years.
- The minimum change is the right change. Legacy software has usually survived for a reason. The agent’s instinct to find the smallest compatible step, rather than the newest everything, is the correct instinct for an enterprise too.
The caveats are real. This was a game with no anti-tamper and no data to lose, and the agent did get things wrong before I read what it was doing. Anything with a compliance surface, a licence server or customer data needs a person in the loop.
The whole thing took an evening, and most of that was the emulator being difficult rather than the patch. As someone with no Android knowledge I found it genuinely surprising how easy it was to do such a patch.