Exact backup and restore of static Android wallpaper state on an unrooted phone, through ADB shell privileges. Built and tested for OnePlus / OxygenOS devices.
wallstate backup original-state.zip
# ... later, or on the same device after a factory reset / OTA experiment ...
wallstate restore original-state.zip
For supported (static) wallpapers, wallstate backup preserves the actual wallpaper
source data and the wallpaper state kept by Android's WallpaperManagerService, so that
a later wallstate restore returns the wallpaper state to the backed-up state:
- the original system wallpaper file, copied byte-for-byte from the framework (never decoded, re-encoded, or re-compressed);
- the original lock wallpaper file when the lock wallpaper is separate;
- the exact crop map (crop hints relative to the original bitmap, per screen orientation) that the service uses to position the wallpaper;
- the system/lock relationship: separate lock wallpaper, or lock inheriting the system wallpaper;
- the wallpaper dim amount where the framework supports it (
getWallpaperDimAmount/setWallpaperDimAmount); - device/build metadata and SHA-256 digests used to verify the restore.
This is a guarantee about wallpaper state, not about physical display pixel-output identity. Wallpaper IDs legitimately change on restore and are recorded as diagnostics only.
Everything runs on the phone as the Android shell uid (2000) through
app_process; there is no APK, no root, no GUI, no Shizuku. The tool talks to
WallpaperManagerService over Binder and falls back to nothing: if the exact state
required for restoration cannot be captured, backup fails closed with a precise
error instead of writing a degraded archive.
- Static image wallpapers only. Live wallpapers, AOD themes, and lock-screen animation packages are detected and rejected with a reason (non-zero exit), never faked.
- Restore refuses archives created on a different device model/SDK, and refuses to run if the required framework methods are missing on the connected build.
- Restore is transactional: it first takes and validates an internal rollback backup of the current state, applies the restore, re-reads the device state and verifies it against the archive; on any failure it automatically rolls back to the previous state.
- A host with Java 17+ and
adb(Android platform-tools). - Android SDK platform android-36 (
android.jar) for the build. - A connected OnePlus/OxygenOS device (Android 15 or 16 tested path), USB debugging enabled. No root required.
./gradlew wallstateJar # produces build/libs/wallstate.jar
./gradlew build # jar + host JVM testsandroid.jar is resolved from local.properties (sdk.dir=...), ANDROID_HOME, or
ANDROID_SDK_ROOT. Install it e.g. with:
sdkmanager "platforms;android-36"or download
platform-36_r02.zip and
unpack android-36/ into <sdk>/platforms/android-36/.
The build compiles Kotlin against the public SDK, dexes the classes (plus the Kotlin
stdlib) with D8, and packages a single classes.dex into wallstate.jar.
Push the jar once (the wrapper does this automatically):
adb push build/libs/wallstate.jar /data/local/tmp/wallstate.jarRun the device CLI directly:
adb shell '
CLASSPATH=/data/local/tmp/wallstate.jar \
app_process /system/bin io.github.jacek4yang.wallstate.Main doctor
'The process must run as uid=2000 (shell); privileged commands verify this at runtime
and fail immediately otherwise.
scripts/wallstate (POSIX sh) is a thin convenience wrapper: it checks for exactly one
attached device (or --serial), pushes the jar, runs the device command, and for
backup/restore moves the archive to/from the host and cleans up temporary files.
scripts/wallstate doctor
scripts/wallstate info
scripts/wallstate backup original-state.zip
scripts/wallstate verify original-state.zip
scripts/wallstate inspect original-state.zip
scripts/wallstate restore original-state.zipOptions: --serial SERIAL (or -s) to pick a device, --jar PATH to override the jar
location. Exit status is propagated from the device command.
| Command | Purpose |
|---|---|
version |
print the tool version |
doctor |
uid, SDK, device, wallpaper service, framework methods, privileges, current wallpaper types, exact backup/restore capability |
info |
current wallpaper state: types, ids, original SHA-256, crop maps, dim |
backup <output.zip> |
create an exact backup (atomically, verified before success) |
inspect <backup.zip> |
show the archive manifest and entries |
verify <backup.zip> |
verify archive integrity: schema, entry set, all SHA-256 digests |
restore <backup.zip> |
transactionally restore the wallpaper state |
set <img> <system|lock|both> [dim] |
test helper: set a wallpaper through the framework write path (used by scripts/acceptance.sh) |
wallstate verify verifies the archive; verification after restore verifies the
device state (original bytes, crop maps, lock relationship, dim amount).
- Original wallpaper bytes are streamed straight from the framework-provided
ParcelFileDescriptorinto the archive; SHA-256 is computed during the copy. - After writing, the archive is re-opened and every recorded digest is re-verified; only then is the temporary file atomically renamed to the requested path.
- If the original stream or the exact crop state cannot be retrieved, backup fails. The cropped wallpaper image is included in the archive for diagnostics only and is never used as a restore input.
- the archive is fully validated (schema, entries, hashes, crop sanity, device compatibility) before anything is changed;
- an internal exact backup of the current state is created and validated;
- the system wallpaper is restored from the archived original plus the archived crop
map through the framework's
setWallpaper(..., screenOrientations, crops, ...)mechanism; - the lock wallpaper is restored according to the archived relationship: a separate lock image is written; an inherited lock is re-established by clearing the lock wallpaper (the framework migrates a shared image to lock-only when the system wallpaper is overwritten, so the clear always follows);
- the dim amount is restored when supported;
- the device state is re-read and verified against the archive;
- on success the rollback backup is deleted; on any failure the rollback is applied
automatically and verified. A failed restore never quietly leaves the phone
half-restored. If rollback itself fails, the command prints
FATAL:and exits 3, keeping the rollback archive for manual recovery.
Versioned ZIP (formatVersion: 1), plain JSON manifest plus raw entries:
backup.zip
├── manifest.json versioned, strict schema
├── system/
│ ├── original byte-identical wallpaper source
│ └── cropped (optional) generated crop, diagnostics only
└── lock/ only when lock is SEPARATE
├── original
└── cropped (optional)
The manifest records toolVersion, creation time, device (manufacturer, model, sdk,
fingerprint), user id, dim state, per-destination originalSha256 / optional
croppedSha256 / crop map (orientation → rect, relative to the original bitmap) /
allowBackup, and the lock mode (SEPARATE or INHERIT_SYSTEM). No Java or Android
objects are serialized. Crop orientations use Android ScreenOrientation constants:
-1 unknown, 0 portrait, 1 landscape, 2 square-portrait, 3 square-landscape; an empty
crop map means the wallpaper uses the framework's default positioning.
Restore treats archives as untrusted input: entries are streamed (never extracted to filesystem paths), entry names are matched against an exact allowlist, duplicates, oversized entries, and malformed manifests are rejected, and every digest is checked before any mutation.
- Static wallpapers only; live/dynamic state is rejected, not preserved.
- v1.0.0 targets the same-device case: restore refuses a different manufacturer/model/SDK and warns on a different build fingerprint of the same device.
- The dim amount is per-calling-uid in the framework; wallstate restores the effective dim amount it observed via the shell uid. A higher dim set by another app remains in effect (framework semantics).
- Crop-state reading prefers
getCurrentBitmapCrops(Android 16+); on Android 15 it falls back togetBitmapCropsfor the current display orientations, which cannot distinguish "no custom crop hints" from "hints equal to the defaults". - OEM-only wallpaper surfaces that are not represented by WallpaperManagerService static wallpaper state (theme-store assets, AOD, lock animations) are out of scope.
v1.0.0 was validated end-to-end on Android 16 (API 36, AOSP google/emu64xa:16/ BE2A.250530.026.F3) running in an emulator on the development machine, including the
real-device acceptance sequence: seed backup, framework-path mutation, restore,
device-state verification for both lock relationships (separate and inherited), a
second backup/restore cycle, and a deliberate failure-injection of the automatic
rollback path.
The tool probes the actual framework at runtime (doctor reports every method it
found or missed) rather than hard-coding assumptions or binder transaction numbers,
so OxygenOS builds that differ from AOSP produce precise diagnostics instead of
undefined behavior. Restore to the same device model/SDK is friction-free; anything
else is rejected up front.
process is not running as shell uid— the jar was executed outsideadb shell(e.g. as root or viasu); run it through adb as-is.NO (missing framework methods: ...)indoctor— the connected build lacks a required hidden API; paste the doctor output in a bug report.wallpaper service: UNAVAILABLE— the system is mid-boot or the service crashed; wait and retry.- Restore refuses with incompatible restore target — the archive was created on a different device/build; wallstate refuses rather than risk wrong crop semantics.
FATAL:on restore — automatic rollback also failed; the phone keeps the last applied state; the rollback archive path is printed, re-runrestorewith it.- On an emulator (or after an unclean device shutdown),
app_processcan abort at startup withClassNotFoundException: io.github.jacek4yang.wallstate.Maineven though the jar is intact: the runtime's dex cache is keyed to the old file state. Re-pushing the jar (the wrapper does this on every run) oradb shell touch /data/local/tmp/wallstate.jarclears it. scripts/acceptance.shruns the full backup/mutate/restore/verify acceptance sequence (both lock-relationship transitions plus a deliberate failure-injection of the rollback path) and always ends by restoring the user's original state.