MovePhone
Verified content migration between two mounted phone trees
Techlosoft - Device Migration Kit
WHAT IT IS
==========
MovePhone moves everything off an old phone onto a new one, and proves it
arrived. It is built for the case where you have to do this fifty times and
cannot afford to hand-check any of them.
A phone is a mounted directory tree - which is exactly how phone storage
presents itself once the handset is plugged in and mounted - so MovePhone works
on directories, and works identically on a laptop, an SD card reader, a network
share or a staging server.
The whole tool rests on one idea: nothing is believed until it has been hashed.
Every file is SHA-256 hashed on the way out, hashed again on the way in, and the
copy is only allowed to take its final name once those two agree. Every item
that lands is written to an append-only ledger, so an interrupted 60 GB move
picks up exactly where it stopped instead of starting over.
Sibling tools cover the neighbouring jobs: DiskWatch forecasts capacity
exhaustion, DrivePulse maps folder space and finds the largest files,
StorageLens and SpaceMedic inspect and diagnose. MovePhone is the only one that
moves anything, and it is the only one that writes.
SCOPE / WHAT THIS DOES NOT DO
=============================
Read this section before you read anything else. MovePhone is deliberately
narrow, and the things it does not do are the things people most often assume a
"phone migration tool" does.
NO MTP, PTP, AFC OR USB PROTOCOL SUPPORT.
MovePhone does not talk to a phone. It has no USB stack, no libimobile
device, no adb, no Android File Transfer, no MTP implementation. It reads
and writes ordinary directory trees. YOU are responsible for getting the
handset mounted as a filesystem first - via your OS's MTP mount, an SD
card in a reader, `adb pull` into a staging directory, or an iPhone import
into a folder. If you cannot see the phone's files in a file manager,
MovePhone cannot see them either.
NO iCLOUD, GOOGLE OR SAMSUNG ACCOUNT MIGRATION.
Nothing is signed into, authenticated against, or downloaded from a cloud
account. Photos that live only in iCloud Photos or Google Photos and are
not materialised as files on the device are invisible to MovePhone. If the
phone shows 40 GB of photos but the mount only exposes 3 GB of thumbnails
and recents, MovePhone will faithfully move 3 GB and tell you so.
NO APP DATA, MESSAGES, CALL HISTORY OR CONTACTS.
SMS/MMS/iMessage threads, WhatsApp and Signal databases, call logs, the
contact and calendar stores, Wi-Fi passwords, keychain and credential
stores, app preferences, game saves and per-app sandboxes are NOT
migrated. Most of them are not files on the exposed mount at all, and the
ones that are (a WhatsApp .db, for example) are encrypted with a key held
in the device keystore that MovePhone has no access to and no business
touching. A .vcf or .ics sitting on the mount as a file is moved like any
other file - as an opaque blob, not as contacts or events.
NO ENCRYPTED-BACKUP HANDLING.
Encrypted iTunes/Finder backups, encrypted Android backups, .abe archives
and anything else key-wrapped are treated as ordinary opaque files. They
are copied byte-for-byte and verified byte-for-byte. They are not
decrypted, not unpacked, not re-encrypted for the new device, and not made
usable on it.
NO APPS ARE INSTALLED, AND NOTHING IS MADE TO "WORK" ON THE NEW PHONE.
MovePhone moves bytes to paths. It does not install applications, does not
register media with the new phone's photo library or media scanner, does
not rebuild thumbnail caches, and does not adjust directory layouts to
match a different OS's conventions. Moving Android's /DCIM/Camera onto an
iPhone-shaped tree gives you the files in a folder, not an iPhone camera
roll.
IT IS A COPY, NOT A MOVE.
Despite the name, nothing is ever deleted from the old phone. The source
tree is opened read-only and there is no code path in the program that
writes to, renames, chmods or removes anything under it. Wiping the old
handset is a decision for a human, taken after `verify` comes back clean.
NO METADATA BEYOND MODIFICATION TIME.
The file's modification time is preserved on a best-effort basis. Owner,
group, permissions, ACLs, extended attributes, resource forks, creation
time and Finder/Android metadata are not carried across. File CONTENT is
what is guaranteed, and it is guaranteed exactly.
NO NETWORK, NO TELEMETRY, NO SCHEDULING, NO GUI.
MovePhone never opens a socket. It does not daemonise, does not install
itself, and reports to nobody.
INSTALL
=======
Pre-built binaries are in dist/. There is nothing to install - copy the one for
your platform anywhere on your PATH and run it.
dist/movephone-linux-amd64 Linux, x86-64
dist/movephone-darwin-arm64 macOS, Apple Silicon
dist/movephone-darwin-amd64 macOS, Intel
dist/movephone-windows-amd64.exe Windows, x86-64
On macOS and Linux you may need to mark it executable:
chmod +x movephone-linux-amd64
To build from source you need Go 1.24 or newer. There are no dependencies of
any kind, so no network access is required:
go build -o movephone .
COMMANDS
========
movephone inventory --src
[--json]
movephone plan --src --dst
[--ledger ] [--json]
movephone transfer --src --dst
--ledger [--apply] [--json]
movephone verify --ledger [--json]
movephone help | -h | --help
inventory
Walks the source tree and, for every regular file, reads it exactly once:
the head of the stream identifies the content and the whole stream feeds a
SHA-256 hash. Reports file and byte totals, a breakdown by category and by
content type, how many files were identified by magic bytes versus by
filename extension, and the dedup-adjusted size.
plan
Inventories both trees and classifies every source item into exactly one of
four classes (below). Reports how many files and bytes will move, what will
be skipped, which names collide, and a time estimate derived from measured
throughput. Writes nothing.
transfer
Executes the plan. DRY RUN BY DEFAULT. Without --apply it prints exactly
what it would do, creates no ledger, does not create a missing destination
directory, and leaves the destination byte-for-byte as it found it. With
--apply it performs the verified copy described under HOW A FILE IS MOVED.
verify
Re-hashes every file the ledger claims was transferred and reports drift.
This is the command you run before you wipe the old handset, and again
weeks later if you want to know the move is still good.
FLAGS
--src The old phone: a mounted directory tree. Opened READ-ONLY.
--dst The new phone: a mounted directory tree. Created on --apply
if it does not exist; never touched otherwise.
--ledger The append-only JSON-lines ledger. Required by transfer and
verify; optional on plan, where it makes the plan show what
a resume would skip. Created on first use along with any
missing parent directories.
--apply Actually perform the transfer. Its absence is the safety.
--json Machine-readable output. Available on all four reporting
commands: inventory, plan, transfer and verify.
Short forms -s, -d and -l are accepted for --src, --dst and --ledger.
Flags may appear before or after positional arguments; either order works.
Bare positional arguments are read as then , so
movephone plan /mnt/old /mnt/new --json
means the same thing as
movephone plan --src /mnt/old --dst /mnt/new --json
QUICK START
===========
# 1. What is actually on the old phone?
movephone inventory --src /mnt/oldphone
# 2. What would the move do? Nothing is written by this.
movephone plan --src /mnt/oldphone --dst /mnt/newphone
# 3. Dry run of the real thing. Still nothing is written.
movephone transfer --src /mnt/oldphone --dst /mnt/newphone --ledger move.jsonl
# 4. Do it.
movephone transfer --src /mnt/oldphone --dst /mnt/newphone --ledger move.jsonl --apply
# 5. If it was interrupted, run the exact same command again. It resumes.
movephone transfer --src /mnt/oldphone --dst /mnt/newphone --ledger move.jsonl --apply
# 6. Prove it before you wipe anything.
movephone verify --ledger move.jsonl
HOW CONTENT IS IDENTIFIED
=========================
Filename extensions on a phone lie. Cameras write HEIC files named .jpg,
messaging apps rename everything, and a file called `notes.txt` is quite often a
photograph. MovePhone therefore reads the first 64 bytes of every file and
identifies it from the actual byte layout.
Identified by MAGIC BYTES:
JPEG FF D8 FF
PNG 89 50 4E 47 0D 0A 1A 0A
GIF "GIF87a" / "GIF89a"
PDF "%PDF-"
ZIP "PK\x03\x04", and the empty/spanned variants
MP4 ISO base media "ftyp" box, brands isom/iso2/mp41/mp42/avc1/
dash/mmp4/3gp...
MOV "ftyp" box with the "qt " brand, OR a classic QuickTime file
whose first top-level atom is moov/mdat/wide/pnot/skip/free
HEIC "ftyp" box with brand heic/heix/hevc/hevx/heim/heis/hevm/hevs/
mif1/msf1
AVIF "ftyp" box with brand avif/avis, kept distinct from HEIC because
it is a different codec
If the major brand of an ISO base media file is unfamiliar, the compatible-brand
list that follows it is checked too. If nothing there is recognised either, the
file is still definitely an ISO base media file and is reported as video/mp4.
Zip is a container, not a format. When the magic bytes prove a zip and the
extension names a known zip-based format (.docx, .xlsx, .pptx, .epub, .apk) the
specific type is reported and the method is recorded as "magic+extension" - both
sources of evidence were used, and the output says so.
Only when sniffing is inconclusive does the filename extension decide, and the
item is then marked "extension". Files that neither method identifies are
reported as application/octet-stream, marked "unrecognised", and moved exactly
like everything else - MovePhone never refuses to move a file it cannot name.
Every item carries its detection method in the output:
magic the content decided it
magic+extension the content proved the container, the name refined it
extension the content decided nothing, the name was used
unrecognised neither; the file is moved as an opaque blob
The inventory prints the totals of each, so "49 by magic bytes, 3 by extension
only, 1 unrecognised" tells you at a glance how much of the identification you
are taking on trust.
THE FOUR PLAN CLASSES
=====================
Every source file falls into exactly one class, decided in this order.
new
Nothing occupies that path at the destination and this content has not
been seen earlier in the source. Transferred.
already-present-identical
The destination already holds a file at that path whose SHA-256 is
identical. Skipped. Nothing is read, written or overwritten. This is
what makes re-running a completed move free.
name-collision-different-content
The destination holds a DIFFERENT file at that path. The existing file
is never overwritten, never renamed and never touched. The incoming
file is written alongside it under a disambiguated name (below) and the
collision is reported in both the plan and the transfer output.
duplicate-within-source
This exact content already appeared at an EARLIER path in the source.
The item is still transferred - it is a real file at a real path on the
old phone and the migration should be faithful - but it is counted
separately so you can see how much of the move is redundant. The
inventory's dedup-adjusted size tells you what you would save by
deduplicating first.
Source items are processed in sorted path order, so "earlier in the source" is
deterministic and two runs always classify the same way.
THE DISAMBIGUATING SUFFIX
A colliding file is written as:
.movephone-<.ext>
DCIM/Camera/IMG_0002.JPG -> DCIM/Camera/IMG_0002.movephone-cdb6e915.JPG
Download/warranty.pdf -> Download/warranty.movephone-de811114.pdf
notes -> notes.movephone-1f0a77c3
The original extension is kept so the file still opens on the new phone. The
suffix is derived from the content, not from a counter, which has two useful
consequences: the same colliding file always lands at the same name, and
re-planning after a transfer reports the file as already-present-identical
rather than colliding all over again. In the astronomically unlikely event that
two different contents share a first-8-hex prefix, -2, -3 ... are appended.
HOW A FILE IS MOVED
===================
For each file to transfer, under --apply:
1. The source is opened READ-ONLY.
2. The destination directory is created if needed.
3. Bytes are streamed to NAME.part while a SHA-256 is computed over the
same stream. The hash is of what was actually written, not of what was
intended.
4. NAME.part is fsynced, so the data is on the medium and not merely in the
OS page cache.
5. The computed hash is compared against the hash recorded for the source
during the inventory pass.
- MISMATCH: NAME.part is deleted, the failure is recorded in the ledger
and reported, and the run CONTINUES with the next file. One bad file
does not abort a migration.
- MATCH: NAME.part is renamed into place. The rename is atomic, so a
file at its final name is always a complete, verified file - there is
no window in which a half-written file wears the real name.
6. The modification time is applied, best effort.
7. The containing directory is fsynced so the rename itself survives power
loss.
8. One line is appended to the ledger and fsynced.
The ledger fsync in step 8 is the reason a resume is trustworthy. It costs real
time on every file, and it buys the guarantee that a ledger line exists only for
a file that genuinely completed.
RESUMING AN INTERRUPTED MOVE
============================
Re-run the identical command. That is the whole interface.
On startup the transfer reads the ledger and builds the set of items this exact
source->destination pair already recorded as verified. It then inventories the
destination, which re-hashes every file there. An item is skipped only when BOTH
are true: the ledger says it was verified, AND the destination file still hashes
to the recorded value right now. The ledger alone is never enough.
If a file the ledger claims is present has been deleted, truncated, or altered
in any way, it is REPAIRED: re-transferred to the same destination name,
reported under "ledger drift", and re-verified.
This is the single exception to "never overwrite", and it is a narrow one. A
destination file that this ledger recorded us writing is our own output, not a
third party's file, and repairing it is the entire point of a resumable verified
transfer. A file we did not write is a name collision and is never overwritten
under any circumstance.
The cost of this design is that a resume re-hashes the destination tree. On a
60 GB move that is minutes of reading. It is the price of never lying about
whether the data is there, and it is not optional.
If the process was killed mid-write, an orphan NAME.part may be left behind. The
next run opens the same temporary name with O_TRUNC and overwrites it, so
orphans are reclaimed rather than accumulating. A ledger whose final line was
torn by a kill is handled too: the incomplete last line is discarded and
everything before it is used. A corrupt line anywhere OTHER than the last is
treated as a damaged file and refused, because that is not something a crash can
produce.
THE TIME ESTIMATE
=================
The estimate is measured, not assumed. Two real numbers go into it:
read+hash Bytes of source divided by the wall-clock time the inventory
pass just spent reading and hashing them. Not a benchmark - the
actual work that just happened.
write+fsync An 8 MiB probe file is written into the destination directory
and fsynced, and the elapsed time is measured. The probe is
then DELETED. If the destination does not exist yet the probe
goes to its nearest existing parent. This is why `plan` can
measure write speed while still leaving the destination
unchanged - nothing of the probe survives the call.
A verified copy does both jobs in series for every file, so the two rates
compose like resistances in series:
1 / effective = 1 / read + 1 / write
estimate = bytes to move / effective
Both measured rates, the sample sizes they came from, and the basis of the
calculation are printed, so you can judge the number instead of trusting it. If
the destination cannot be probed (read-only mount, no permission) the estimate
falls back to the read rate alone and says plainly that it is optimistic because
the write side is unaccounted for.
The estimate does not model per-file overhead, so a tree of a million tiny files
will beat the estimate badly. It is honest about throughput, not about syscalls.
EXAMPLE OUTPUT
==============
$ movephone plan --src oldphone --dst newphone
MovePhone transfer plan
source : /mnt/oldphone (53 files, 53.8 MiB)
dest : /mnt/newphone (4 files, 1.8 MiB)
CLASSIFICATION
new 48
already-present-identical 1
name-collision-different-content 2
duplicate-within-source 2
to move : 52 files, 53.1 MiB (55724047 bytes)
to skip : 1 files, 683.6 KiB already identical at the destination
redundant : 1.0 MiB of the move is duplicate content within the source
TIME ESTIMATE (from measurement, not a guess)
read+hash : 1.0 GiB/s (measured over 53.8 MiB of source)
write+fsync : 260.3 MiB/s (measured with an 8.0 MiB probe written to and
removed from /mnt/newphone)
effective : 207.6 MiB/s
estimate : 256 ms (0.26 s)
basis : measured read+hash of the source and a measured write+fsync
probe in the destination
NAME COLLISIONS (2) - the destination file is NEVER overwritten
DCIM/Camera/IMG_0002.JPG
-> DCIM/Camera/IMG_0002.movephone-cdb6e915.JPG
Download/warranty.pdf
-> Download/warranty.movephone-de811114.pdf
A resume after the transfer was killed part-way through:
$ movephone transfer --src oldphone --dst newphone --ledger move.jsonl --apply
MovePhone transfer complete
ledger : /mnt/move.jsonl (18 entries before this run)
transferred : 34 files, 41.1 MiB (43123849 bytes), all hash-verified
skipped (resumed): 18 files, 12.0 MiB already recorded verified in the ledger
skipped (same) : 1 files already identical at the destination
collisions : 1 renamed, 0 overwritten
duplicates : 2 source files were byte-identical to an earlier one
ledger drift : 0 re-transferred because the destination no longer matched
failed : 0
elapsed : 185 ms (221.3 MiB/s achieved)
LEDGER FILE FORMAT
==================
One JSON object per line, UTF-8, newline-terminated, appended and fsynced in
completion order. Never rewritten, never reordered, never compacted.
{"ts":"2026-08-11T07:07:05.253210954Z",
"src":"/mnt/oldphone","dst":"/mnt/newphone",
"rel":"DCIM/Camera/IMG_0002.JPG",
"dest_rel":"DCIM/Camera/IMG_0002.movephone-cdb6e915.JPG",
"bytes":700011,"sha256":"cdb6e915fb2af923...",
"class":"name-collision-different-content","status":"verified"}
ts RFC 3339 UTC timestamp, nanosecond precision
src absolute source root as resolved at transfer time
dst absolute destination root
rel path of the file relative to the source root, slash-separated
dest_rel path it was actually written to, relative to the destination root.
Differs from rel only when a collision was disambiguated.
bytes bytes actually written
sha256 the hash that was verified
class the plan class this item was transferred under
status "verified" or "failed"
detail present on failures, explaining what went wrong
Because src and dst are recorded per line, one ledger can safely carry several
migrations; resume and verify only ever consider entries matching the pair they
were invoked with. Paths are slash-separated in the ledger regardless of
platform, so a ledger written on Linux is readable on Windows.
EDGE CASES, HANDLED EXPLICITLY
==============================
overlapping roots A destination inside the source, a source inside the
destination, or the two being the same directory is
refused before anything is read. A transfer feeding
on its own output cannot be made correct.
ledger inside source Refused. The source is never written to, and that
includes the ledger.
symbolic links Never followed and never inventoried. A phone mount
can contain links pointing back out of the tree;
copying through them would duplicate content or
escape the destination. They are listed under
SKIPPED so their absence is visible.
devices, sockets, FIFOs Skipped and listed, for the same reason.
unreadable files Counted, listed under SKIPPED, and never fatal. A
permission-denied subdirectory does not abort a
migration.
empty files Inventoried, hashed (the SHA-256 of nothing is a
real hash), and transferred like anything else.
empty directories NOT recreated at the destination. Directories are
created only as parents of files being written, so
a source directory containing nothing simply does
not appear on the new phone.
zero-byte destination A truncated destination file has a different hash
and is caught by verify and repaired by resume.
missing destination Created by --apply, along with every parent
directory. NOT created by plan or by a dry run.
hash mismatch on copy The partial file is deleted, the failure is recorded
in the ledger, the item is reported, and the run
continues. The exit code becomes 2.
duplicate hard links Counted once per path encountered, so a file with
two links inside the source is inventoried twice and
transferred twice, as two independent files.
EXIT CODES
==========
0 Success. Collisions, duplicates, skipped-identical files and "nothing to
do" are all legitimate answers, not failures. Explicit help exits 0.
1 Bad invocation (unknown command, missing required flag, overlapping
roots) or an I/O error that stopped the command (missing source,
unreadable ledger, corrupt ledger line).
2 The command ran to completion but reported failures: at least one file
failed its hash verification during transfer, or verify found drift.
Exit code 2 is the one to alert on in a script. It means the tool worked and the
data did not.
WHAT IS IMPLEMENTED, AND WHAT IS NOT
====================================
IMPLEMENTED, AND WORKING TODAY
- Content identification by magic bytes for JPEG, PNG, GIF, PDF, ZIP, MP4,
MOV, HEIC and AVIF, with an extension fallback and per-item reporting of
which method was used.
- SHA-256 hashing of every file, on a single read pass shared with the sniff.
- Per-category and per-content-type breakdowns, and dedup-adjusted sizing.
- Four-way plan classification, with deterministic ordering.
- Collision handling by content-derived rename, which never overwrites and is
idempotent across re-plans.
- Verified copy: hash-while-streaming, fsync, compare, atomic rename, dir
fsync.
- Failure isolation: a bad file is recorded and skipped, not fatal.
- Append-only, per-line-fsynced JSON-lines ledger.
- Resume that re-hashes the destination before trusting the ledger, and
repairs drifted files.
- Torn-final-line tolerance in the ledger.
- verify: full re-hash of everything the ledger claims, with missing /
size-changed / content-changed / unreadable drift reporting.
- Time estimates from measured read and measured write throughput.
- --json on inventory, plan, transfer and verify.
- Dry run by default on transfer; --apply required to write anything.
- Read-only source, enforced by construction and covered by tests.
NOT IMPLEMENTED - HONESTLY ABSENT, NOT STUBBED
- Everything in the SCOPE section above: no MTP/AFC/PTP, no cloud accounts, no
app data or messages or call history, no encrypted-backup handling.
- Parallelism. Files are transferred one at a time. On a fast NVMe destination
a concurrent copier would be several times quicker; on the USB 2.0 MTP mount
this tool exists to serve, it would not.
- Resumption WITHIN a single file. Interrupting a 4 GB video means that video
restarts from zero on the next run. Every other file's progress is kept.
Byte-range resume of a partial .part file is not implemented.
- Deduplication on write. Duplicates within the source are reported and
counted, but they are still transferred as separate files. Nothing is
hardlinked or reflinked at the destination.
- Include/exclude filters, size or date thresholds, and per-category
selection. It is all-or-nothing today; pre-filter the tree yourself.
- Directory restructuring, media-library registration, or any translation
between Android and iOS layout conventions.
- Deleting or verifying-then-wiping the source. Deliberately absent.
- Permissions, ownership, ACLs, xattrs, resource forks and creation times.
- Recreation of empty directories, and migration of symlinks as symlinks.
Links are skipped and listed, not followed and not recreated.
- Progress output during a long transfer. The report is printed at the end.
A killed run's ledger is the only progress indicator, and `wc -l` on it
works well for that.
- Bandwidth limiting, retries with backoff, and resumable network transports.
- Any check that the destination has enough free space before starting. Plan
tells you how many bytes are coming; compare it against the target yourself.
(DiskWatch, in this same tool line, is the thing that measures free space.)