Getting My Own Purchases Out of the New Black Library App: Split-APK Signing, `run-as`, and the DRM That Never Existed
Games Workshop is retiring BlackLibrary.com. The new Black Library: The App launched on 18 March 2026, the old web store closes on 31 August, and the migration funnels everything through a MyWarhammer account into a single app with a built-in eReader and MP3 player. The convenience is real. So is the change in ownership model: where I used to download an EPUB or an MP3 and drop it into whatever reader I liked, the files now live inside an app, tied to an account, one Games Workshop policy decision away from being someone else’s problem.
That is the whole motivation. Not piracy, not resale. Keeping a local copy of books I already paid for, in formats that outlive a vendor’s product roadmap. The catch surfaced immediately: a title only exists on disk after you download it inside the app. So step zero is mundane. Open the app, tap download, then start digging.
This is a Solve log. It has more dead ends than fixes, which is the honest ratio.
The setup
- Device: Fairphone 4, non-rooted, modern Android (12+).
- App:
com.gamesworkshop.bl, version 1.1.0 (7), React Native, shipped as a split APK (abase.apkplussplit_config.arm64_v8a.apkandsplit_config.xxhdpi.apk). The resource table gives the renderer away:readium_navigator_viewpager_fragment_epub.xml. So Readium under the hood, and a bundledlibsqlite3x.so. - Host: Fedora, with the Android SDK build-tools already installed at
~/Android/sdk/build-tools/36.0.0/.
The objective was narrow: read the app’s private files directory. On a non-rooted device that is the entire difficulty, since Android keeps /data/data/<pkg>/ sealed from the regular shell user.
The hunt
Dead end one: adb backup
The textbook non-root trick is the backup loophole. It produced a 24-byte header and nothing else.
1
2
adb backup -f bl_backup.ab -noapk com.gamesworkshop.bl
dd if=bl_backup.ab bs=24 skip=1 | zlib-flate -uncompress > bl_backup.tar # empty
Two reasons it was never going to work, and both should have been checked first: Android deprecated adb backup from 12 onward and silently declines to pull app data, and any app shipping android:allowBackup="false" closes the door regardless. Verifying the manifest flag before running the command would have saved the detour.
Dead end two: patch only the base
If backups are out, the next lever is android:debuggable="true", which unlocks run-as without root. Decompile the base, flip the flag, rebuild:
1
2
3
apktool d base.apk -o bl_decompiled
# set android:debuggable="true" (and allowBackup) in AndroidManifest.xml
apktool b bl_decompiled -o bl_modified.apk
Install failed with INSTALL_FAILED_MISSING_SPLIT. Predictable in hindsight. The app declares itself a split bundle, so Android refuses a lone base and demands the architecture and density splits alongside it.
Dead end three: mix signatures
Install the patched base together with the original splits, then:
1
2
adb install-multiple bl_patched.apk split_config.arm64_v8a.apk split_config.xxhdpi.apk
# Failure [INSTALL_FAILED_INVALID_APK: ... signatures are inconsistent]
The patched base now carries my debug certificate while the two splits still carry the Games Workshop production key. Android tolerates many things, but it will not assemble one app from packages signed by different parties.
The branch I rejected: merge everything into one APK
The thorough route is to dissolve the split structure entirely: drop the native .so files from the arm64 split into the decompiled base, strip android:isSplitRequired, android:splitTypes, android:requiredSplitTypes and the com.android.vending.splits metadata, then rebuild as a single standalone package. It is doable. It is also where the time goes, because hand-editing the decompiled folder before re-running apktool b deletes the apktool.yml it expects and the build panics with NoSuchFileException: apktool.yml. A clean re-decompile fixes that, but the resource and native-library merge of a React Native app is exactly the class of fiddly that turns an afternoon into an evening. Given that a simpler option existed, this path was not pursued further.
The fix: one key for all three
The minimal-surface solution is to leave the split layout intact and make the signatures agree. Android does not care who signs the bundle, only that every member shares one signature. So re-sign the untouched splits with the same debug key as the patched base:
1
2
3
4
5
6
7
8
9
10
APKSIGNER=~/Android/sdk/build-tools/36.0.0/apksigner
$APKSIGNER sign --ks ~/.android/debug.keystore --ks-pass pass:android \
--key-pass pass:android --out split_arm64_signed.apk split_config.arm64_v8a.apk
$APKSIGNER sign --ks ~/.android/debug.keystore --ks-pass pass:android \
--key-pass pass:android --out split_xxhdpi_signed.apk split_config.xxhdpi.apk
adb uninstall com.gamesworkshop.bl
adb install-multiple bl_patched.apk split_arm64_signed.apk split_xxhdpi_signed.apk
# Success
apksigner verify checks one file per invocation, so verifying that all three certificate digests matched meant three separate calls rather than the one I reflexively tried. Minor, but it is the kind of detail the tool enforces and the docs underplay.
Crossing the boundary
With a debuggable build installed, run-as works:
1
2
3
4
adb shell
run-as com.gamesworkshop.bl
ls -la files/downloaded_products/
# -rw------- ... dd90b11e-20c3-4543-9ef2-6551b665f531 (~1.6 MB)
Downloaded titles sit in files/downloaded_products/, named as bare UUIDs with no extension. The obvious move, copying to /sdcard/ and pulling, hits a wall:
1
cp: /sdcard/Download/target_book: Permission denied
Scoped storage. The app’s UID cannot write the shared partition without a runtime grant. The way around it is to never leave the sandbox’s read context: have run-as cat the file and let the byte stream ride the ADB pipe straight onto the host.
1
2
3
4
5
mkdir -p ~/Desktop/bl_extracted_books
for f in $(adb shell "run-as com.gamesworkshop.bl ls files/downloaded_products/"); do
adb shell "run-as com.gamesworkshop.bl cat files/downloaded_products/$f" \
> ~/Desktop/bl_extracted_books/$f
done
The findings
Two files came across. file settles what they are:
1
2
dd90b11e-...: EPUB document
148a7c33-...: Audio file with ID3 version 2.4.0, MPEG ADTS, layer III, v1, 48 kbps, 44.1 kHz, Monaural
A plain EPUB and a plain MP3. No encryption, no proprietary container, no per-device key derived from a secret in the JS bundle. Rename, and Foliate or any MP3 player opens them.
That is the anticlimax worth stating plainly: the protection was never cryptographic. It was Android’s app sandbox doing exactly its job. Everything upstream, the splits, the signatures, the debuggable patch, was effort spent reaching a directory the OS guards, not effort spent defeating DRM. The audiobook arriving as 48 kbps mono is a separate, slightly deflating observation about what “audiobook” means here.
What this changes, and the caveats
The mental model I walked in with was wrong. I expected AES and a key hunt through shared_prefs; the actual barrier was process isolation, and the whole exercise reduces to “make Android trust a debuggable rebuild.” If I did it again I would check allowBackup and the split structure in the manifest before touching anything, which would have collapsed three dead ends into one read.
Three things temper the result. It only applies to titles you have purchased and downloaded in the app first, so the app remains a required intermediary. The resigned build no longer matches the Play Store’s signature, which means Play updates for the app break and future versions have to be sideloaded the same way. And whatever the technical ease, doing this still sits against the service’s terms, so the reasonable scope is personal archival of your own library, nothing further.
The files are mine, and now they sit on a disk I control rather than behind a login that has an end-of-life date. For a migration whose entire selling point is convenience, that the underlying assets turned out to be standard, openable formats is the one genuinely reassuring detail in the whole log.