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.)