The problem#
Last time, I wrote about figuring out how to access my Android phone's filesystem from the command line. As I said, the actual problem I wanted to solve was to minimize the manual effort in regularly backing up the important files on my phone.
The solution#
I wrote a script, backup-phone.py to implement the
logic I was following to backup my phone manually. My backup structure
is that each time I made a new backup, I would create a directory named
for the current date (e.g. 2026-08-16/) and mirror the directory
structure of the phone, copying over the files that were not in any
prior backup from the directories I was backing up.
To set up the script, once your phone is mounted to /path/to/phone/,
navigate to the directory you want to store your backups in and run the
script with the --config option to initialize the config file:
$ cd /path/to/backup/
$ backup-phone.py --config /path/to/phone/
In CONFIG mode. Only the config file may be modified.
Configuration needs to be updated. Check the config file.
Writing modified config file. Backed up previous version to /path/to/backup/config.bak-20260816131436.json
That will create a file config.json. To configure the backup, open
that file in a text editor. It will look like
{
"backup_dir_strftime": "%Y-%m-%d",
"ignored_backup_dirs": [],
"root": {
"action": "TODO:ignore_files|backup_single_newest|backup_all_new|sync_all_new"
}
}
Edit the "action" line to the desired action (e.g. "ignore_files" to
ignore the files but still descend into the subdirectories), rerun the
script with --config and it will be updated. If there are any new
subdirectories found, you will need to provide actions for them:
{
"backup_dir_strftime": "%Y-%m-%d",
"ignored_backup_dirs": [],
"root": {
"action": "ignore_files",
"subdirectories": {
"DCIM": {
"action": "TODO:ignore_files|backup_single_newest|backup_all_new|sync_all_new"
},
"Downloads": {
"action": "TODO:ignore_files|backup_single_newest|backup_all_new|sync_all_new"
}
}
}
}
If you want to ignore all subdirectories of a directory, set
"subdirectories" to null.
Once a selection has been made for all directories, running with --config
will display the following:
$ backup-phone.py --config /path/to/phone/
In CONFIG mode. Only the config file may be modified.
Configuration is valid.
Note that if a new directory is added in the future, then the backup
will fail until you run with --config again and provide an action for
that directory. This is intentional so you have to actively choose to
not backup a directory.
Then the --dry-run flag can be provided to see what would be copied.
Or with no flag, it will perform the actual backup.
The details#
Configuration UX design#
The back-and-forth of the user and the program editing the config.json
file is an idiosyncratic way of designing a configuration interface. My
reason for doing it that way is that I was willing to keep the script
simple at the cost of a bit of usability. I didn't want to code a UI if
I didn't have to, but the configuration for a backup is too involved to
be specified just with command-line flags. On the other hand, having the
configuration file be entirely human-written seemed silly since most
of it is just reflecting the directory structure that the computer is
reading. I did initially consider having the script ask questions at the
command-line instead of writing out a file with blanks to fill in, but
that both seemed like more work to code and more awkward to use.
Showing progress bars#
Since some of the files are large and can take a while to copy, I
wanted the copy to show some indication of the progress of copying
each individual file. Once again, I didn't want to put too much
coding effort in, and that's something that already exists. So the
copies are actually performed by rsync with the
--progress flag.
This feature can be disabled by passing the --python-copy flag to
backup-phone.py, which will make it perform the copies using the
Python standard library function pathlib.Path.copy_into()
instead of calling out to rsync.
Copying to phone#
The backup can actually be a two-way sync. The "sync_all_new" action
takes a "sync_from" directory and an optional "glob" (e.g. *.mp3)
and copies those files from the host to the phone.
One catch is that rsync's default settings fail to write files on
the phone:
$ touch /tmp/afile
$ rsync /tmp/afile ./
sending incremental file list
afile
0 100% 0.00kB/s 0:00:00 (xfr#1, to-chk=0/1)
rsync: [receiver] mkstemp "/run/user/1234/gvfs/mtp\:host\=Android_GK3SO72EF4X/.afile.ZScN3Q" failed: Operation not supported (95)
sent 108 bytes received 35 bytes 286.00 bytes/sec
total size is 0 speedup is 0.00
rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1356) [sender=3.4.4]
mkstemp is a way of creating uniquely named temporary
files to make sure the caller really is the only one with control over
that file. rsync by default uses it to create a temporary file to
write the data it is copying to which is then moved to the actual target
filename. That avoids confusion over the state of the target file if the
transfer is interrupted.
For whatever reason, the mounted MTP filesystem does not support that
operation. As a workaround, that feature of rsync can be disabled
with the --inplace flag:
$ rsync --inplace /tmp/afile ./
sending incremental file list
afile
0 100% 0.00kB/s 0:00:00 (xfr#1, to-chk=0/1)
rsync: [receiver] failed to set times on "/run/user/1234/gvfs/mtp\:host\=Android_GK3SO72EF4X/afile": Operation not supported (95)
sent 108 bytes received 35 bytes 286.00 bytes/sec
total size is 0 speedup is 0.00
rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1356) [sender=3.4.4]
It shows a different error, but successfully copies the file.
In the interest of not displaying errors, backup-phone.sh just doesn't
use rsync when copying file to the phone, only when copying files
from the phone.
Do note that copy_into() does have an option
preserve_metadata, but that similarly generates errors:
>>> import pathlib
>>> here = pathlib.Path('.')
>>> afile = pathlib.Path('/tmp/afile')
>>> afile.copy_into(here, preserve_metadata=True)
Traceback (most recent call last):
File "<python-input-3>", line 1, in <module>
afile.copy_into(here, preserve_metadata=True)
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/pathlib/__init__.py", line 1118, in copy_into
return self.copy(target, **kwargs)
~~~~~~~~~^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/pathlib/__init__.py", line 1104, in copy
target._copy_from(self, **kwargs)
~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/pathlib/__init__.py", line 1135, in _copy_from
self._copy_from_file(source, preserve_metadata)
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/pathlib/__init__.py", line 1143, in _copy_from_file
copy_info(source.info, self)
~~~~~~~~~^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/pathlib/_os.py", line 264, in copy_info
os.utime(target, ns=(t0, t1), follow_symlinks=follow_symlinks)
~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
OSError: [Errno 95] Operation not supported: 'afile'
>>> afile.copy_into(here)
PosixPath('afile')
Getting data into files#
In order to include contacts and text messages in the backup, I use
Slight Backup to output them to the filesystem. Since
that requires manually invoking the backup, "require_file": true can
be set for a directory to indicate if there's no files to backup, that's
an error.
Unfortunately, Slight Backup does not appear to be available anymore as it has not been updated in a long time. It's still working for me, so I haven't tried alternatives, but it looks like SD Contacts and Epistolaire would accomplish the same goal.
Backup the backups#
Simply copying the files onto another machine's main disk is not particularly great reliability for a backup. This script is just about getting the files off the phone; the directory it copies the files into is part of my computer's regular backups which are stored in multiple places.
Comments
Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.
There are no comments yet.