diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6305fc5 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +adb-sync +======== + +adb-sync is a tool to synchronize files between a PC and an Android device +using the ADB (Android Debug Bridge). + +Related Projects +================ + +Before getting used to this, please review this list of projects that are +somehow related to adb-sync and may fulfill your needs better: + +* [http://rsync.samba.org/](rsync) is a file synchronization tool for local + (including FUSE) file systems or SSH connections. This can be used even with + Android devices if rooted or using an app like + [https://play.google.com/store/apps/details?id=com.arachnoid.sshelper](SSHelper). +* [http://collectskin.com/adbfs/](adbfs) is a FUSE file system that uses adb to + communicate to the device. Requires a rooted device, though. +* [https://github.com/spion/adbfs-rootless](adbfs-rootless) is a fork of adbfs + that requires no root on the device. Does not play very well with rsync. +* [https://github.com/hanwen/go-mtpfs](go-mtpfs) is a FUSE file system to + connect to Android devices via MTP. Due to MTP's restrictions, only a certain + set of file extensions is supported. To store unsupported files, just add + .txt! Requires no USB debugging mode. + +Setup +===== + +Android Side +------------ + +First you need to enable USB debugging mode. This allows authorized computers +(on Android before 4.4.3 all computers) to perform possibly dangerous +operations on your device. If you do not accept this risk, do not proceed and +try using [https://github.com/hanwen/go-mtpfs](go-mtpfs) instead! + +On your Android device: + +* Go to the Settings app. +* If there is no "Developer Options" menu: + * Select "About". + * Tap "Build Number" seven times. + * Go back. +* Go to "Developer Options". +* Enable "USB Debugging". + +PC Side +------- + +* Install the [http://developer.android.com/sdk/index.html](Android SDK) (the + stand-alone Android SDK "for an existing IDE" is sufficient). Alternatively, + some Linux distributions come with a package named like "android-tools-adb" + that contains the required tool. +* Make sure "adb" is in your PATH. If you use a package from your Linux + distribution, this should already be the case; if you used the SDK, you + probably will have to add an entry to PATH in your ~/.profile file, log out + and log back in. +* `git clone https://github.com/google/adb-sync` +* `cd adb-sync` +* Copy or symlink the adb-sync script somewhere in your PATH. For example: + `cp adb-sync /usr/local/bin/` + +Usage +===== + +To get a full help, type: + +``` +adb-sync --help +``` + +To synchronize your music files from ~/Music to your device, type: + +``` +adb-sync ~/Music /sdcard/Music +``` + +To synchronize your music files from ~/Music to your device, deleting files you +removed from your PC, type: + +``` +adb-sync --delete ~/Music /sdcard/Music +``` + +To copy all downloads from your device to your PC, type: + +``` +adb-sync --reverse /sdcard/Download ~/Downloads +``` + +Contributing +============ + +Patches to this project are very welcome. + +Before sending a patch or pull request, we ask you to fill out one of the +Contributor License Agreements: + +* [https://developers.google.com/open-source/cla/individual](Google Individual Contributor License Agreement, v1.1) +* [https://developers.google.com/open-source/cla/corporate](Google Software Grant and Corporate Contributor License Agreement, v1.1) + +Disclaimer +========== + +This is not an official Google product. diff --git a/adb-sync b/adb-sync new file mode 100755 index 0000000..0562dd2 --- /dev/null +++ b/adb-sync @@ -0,0 +1,655 @@ +#!/usr/bin/python + +# Copyright 2014 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Sync files from/to an Android device.""" + +from __future__ import print_function +from __future__ import unicode_literals +import argparse +import os +import re +import stat +import subprocess +import sys +import time + + +class AdbFileSystem(object): + """Mimics os's file interface but uses the adb utility.""" + + def __init__(self, adb): + self.stat_cache = {} + self.adb = adb.split(b' ') + + # Regarding parsing stat results, we only care for the following fields: + # - st_size + # - st_mtime + # - st_mode (but only about S_ISDIR and S_ISREG properties) + # Therefore, we only capture parts of 'ls -l' output that we actually use. + # The other fields will be filled with dummy values. + LS_TO_STAT_RE = re.compile(r'''^ + (?: + (?P -) | + (?P b) | + (?P c) | + (?P d) | + (?P l) | + (?P p) | + (?P s)) + [-r][-w][-xsS] + [-r][-w][-xsS] + [-r][-w][-xtT] # Mode string. + [ ] + [^ ]+ # User name/ID. + [ ]+ + [^ ]+ # Group name/ID. + [ ]+ + (?(S_IFBLK) [^ ]+[ ]+[^ ]+[ ]+) # Device numbers. + (?(S_IFCHR) [^ ]+[ ]+[^ ]+[ ]+) # Device numbers. + (?(S_IFREG) + (?P [0-9]+) # Size. + [ ]+) + (?P + [0-9]{4}-[0-9]{2}-[0-9]{2} # Date. + [ ] + [0-9]{2}:[0-9]{2}) # Time. + [ ] + # Don't capture filename for symlinks (ambiguous). + (?(S_IFLNK) .* | (?P .*)) + $''', re.DOTALL | re.VERBOSE) + def LsToStat(self, line): + """Convert a line from 'ls -l' output to a stat result. + + Args: + line: Output line of 'ls -l' on Android. + + Returns: + os.stat_result for the line. + + Raises: + OSError: if the given string is not a 'ls -l' output line (but maybe an + error message instead). + """ + + match = self.LS_TO_STAT_RE.match(line) + if match is None: + print('Warning: could not parse %r.' % line) + raise OSError('Unparseable ls -al result.') + groups = match.groupdict() + + # Get the values we're interested in. + st_mode = ( # 0755 + stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + if groups['S_IFREG']: st_mode |= stat.S_IFREG + if groups['S_IFBLK']: st_mode |= stat.S_IFBLK + if groups['S_IFCHR']: st_mode |= stat.S_IFCHR + if groups['S_IFDIR']: st_mode |= stat.S_IFDIR + if groups['S_IFIFO']: st_mode |= stat.S_IFIFO + if groups['S_IFLNK']: st_mode |= stat.S_IFLNK + if groups['S_IFSOCK']: st_mode |= stat.S_IFSOCK + st_size = groups['st_size'] + if st_size is not None: + st_size = int(st_size) + st_mtime = time.mktime(time.strptime(match.group('st_mtime').decode('utf-8'), + '%Y-%m-%d %H:%M')) + + # Fill the rest with dummy values. + st_ino = 1 + st_rdev = 0 + st_nlink = 1 + st_uid = -2 # Nobody. + st_gid = -2 # Nobody. + st_atime = st_ctime = st_mtime + + stbuf = os.stat_result((st_mode, st_ino, st_rdev, st_nlink, st_uid, st_gid, + st_size, st_atime, st_mtime, st_ctime)) + filename = groups['filename'] + return stbuf, filename + + def Stdout(self, *popen_args): + """Closes the process's stdout when done. + + Usage: + with Stdout(...) as stdout: + DoSomething(stdout) + + Args: + popen_args: Arguments for subprocess.Popen; stdout=PIPE is implicitly + added. + + Returns: + An object for use by 'with'. + """ + + class Stdout(object): + def __init__(self, popen): + self.popen = popen + + def __enter__(self): + return self.popen.stdout + + def __exit__(self, exc_type, exc_value, traceback): + self.popen.stdout.close() + if self.popen.wait() != 0: + raise OSError('Subprocess exited with nonzero status.') + return False + + return Stdout(subprocess.Popen(*popen_args, stdout=subprocess.PIPE)) + + def QuoteArgument(self, arg): + # Quotes an argument for use by adb shell. + # Usually, arguments in 'adb shell' use are put in double quotes by adb, + # but not in any way escaped. + arg = arg.replace(b'\\', b'\\\\') + arg = arg.replace(b'"', b'\\"') + arg = arg.replace(b'$', b'\\$') + arg = arg.replace(b'`', b'\\`') + # Sometimes adb is evil and puts us NOT in double quotes. + # So here's a horrible hack to always force double quote mode. + # Thanks to autoconf guys for describing this method in portable shell. + # $PATH is assumed to always be set, even on Android. + arg = b'${PATH+"' + arg + b'"}' + return arg + + def IsWorking(self): + """Tests the adb connection.""" + # This string should contain all possible evil, but no percent signs. + # Note this code uses 'date' and not 'echo', as date just calls strftime + # while echo does its own backslash escape handling additionally to the + # shell's. Too bad printf "%s\n" is not available. + test_strings = [ + b'(', + b'(; #`ls`$PATH\'"(\\\\\\\\){};!\xc0\xaf\xff\xc2\xbf' + ] + for test_string in test_strings: + good = False + with self.Stdout(self.adb + [b'shell', b'date', + b'+' + self.QuoteArgument(test_string)]) as stdout: + for line in stdout: + line = line.rstrip(b'\r\n') + if line == test_string: + good = True + if not good: + return False + return True + + def listdir(self, path): # os's name, so pylint: disable=g-bad-name + """List the contents of a directory.""" + with self.Stdout(self.adb + [b'shell', b'ls', b'-a', + self.QuoteArgument(path)]) as stdout: + for line in stdout: + yield line.rstrip(b'\r\n') + + def CacheDirectoryLstat(self, path): + """Cache lstat for a directory.""" + with self.Stdout(self.adb + [b'shell', b'ls', b'-al', + self.QuoteArgument(path + b'/')]) as stdout: + for line in stdout: + line = line.rstrip(b'\r\n') + try: + statdata, filename = self.LsToStat(line) + except OSError: + continue + if filename is None: + print('Warning: could not cache %s' % + line.decode('utf-8', errors='replace')) + else: + self.stat_cache[path + b'/' + filename] = statdata + + def lstat(self, path): # os's name, so pylint: disable=g-bad-name + """Stat a file.""" + if path in self.stat_cache: + return self.stat_cache[path] + with self.Stdout(self.adb + [b'shell', b'ls', b'-ald', + self.QuoteArgument(path)]) as stdout: + for line in stdout: + line = line.rstrip(b'\r\n') + statdata, filename = self.LsToStat(line) + self.stat_cache[path] = statdata + return statdata + raise OSError('No such file or directory') + + def unlink(self, path): # os's name, so pylint: disable=g-bad-name + """Delete a file.""" + if subprocess.call(self.adb + [b'shell', b'rm', path]) != 0: + raise OSError('unlink failed') + + def rmdir(self, path): # os's name, so pylint: disable=g-bad-name + """Delete a directory.""" + if subprocess.call(self.adb + [b'shell', b'rmdir', path]) != 0: + raise OSError('rmdir failed') + + def makedirs(self, path): # os's name, so pylint: disable=g-bad-name + """Create a directory.""" + if subprocess.call(self.adb + [b'shell', b'mkdir', b'-p', path]) != 0: + raise OSError('mkdir failed') + + def utime(self, path, times): + # TODO(rpolzer): Find out why this does not work (returns status 255). + """Set the time of a file to a specified unix time.""" + atime, mtime = times + timestr = time.strftime(b'%Y%m%d.%H%M%S', time.localtime(mtime)) + if subprocess.call(self.adb + [b'shell', b'touch', b'-mt', + timestr, path]) != 0: + raise OSError('touch failed') + timestr = time.strftime(b'%Y%m%d.%H%M%S', time.localtime(atime)) + if subprocess.call(self.adb + [b'shell', b'touch', b'-at', + timestr, path]) != 0: + raise OSError('touch failed') + + def Push(self, src, dst): + """Push a file from the local file system to the Android device.""" + if subprocess.call(self.adb + [b'push', src, dst]) != 0: + raise OSError('push failed') + + def Pull(self, src, dst): + """Pull a file from the Android device to the local file system.""" + if subprocess.call(self.adb + [b'pull', src, dst]) != 0: + raise OSError('pull failed') + + +def BuildFileList(fs, path, prefix=b''): + """Builds a file list. + + Args: + fs: File system provider (can be os or AdbFileSystem()). + path: Initial path. + prefix: Path prefix for output file names. + + Yields: + File names from path (prefixed by prefix). + Directories are yielded before their contents. + """ + try: + statresult = fs.lstat(path) + except OSError: + return + if stat.S_ISDIR(statresult.st_mode): + yield prefix, statresult + try: + files = list(fs.listdir(path)) + except OSError: + return + try: + if hasattr(fs, 'CacheDirectoryLstat'): + fs.CacheDirectoryLstat(path) + except OSError: + print('Warning: could not cache lstat for %s' % + path.decode('utf-8', errors='replace')) + files.sort() + for n in files: + for t in BuildFileList(fs, path + b'/' + n, prefix + b'/' + n): + yield t + elif stat.S_ISREG(statresult.st_mode): + yield prefix, statresult + else: + print('Note: unsupported file: %s' % path.decode('utf-8', errors='replace')) + + +def DiffLists(a, b): + """Compares two lists. + + Args: + a: the first list. + b: the second list. + + Returns: + a_only: the items from list a. + both: the items from both list, with the remaining tuple items combined. + b_only: the items from list b. + """ + a_only = [] + b_only = [] + both = [] + + a_iter = iter(a) + b_iter = iter(b) + a_active = True + b_active = True + a_available = False + b_available = False + a_item = None + b_item = None + + while a_active and b_active: + if not a_available: + try: + a_item = next(a_iter) + a_available = True + except StopIteration: + a_active = False + break + if not b_available: + try: + b_item = next(b_iter) + b_available = True + except StopIteration: + b_active = False + break + if a_item[0] == b_item[0]: + both.append(tuple([a_item[0]] + list(a_item[1:]) + list(b_item[1:]))) + a_available = False + b_available = False + elif a_item[0] < b_item[0]: + a_only.append(a_item) + a_available = False + elif a_item[0] > b_item[0]: + b_only.append(b_item) + b_available = False + else: + raise + + if a_active: + if a_available: + a_only.append(a_item) + for item in a_iter: + a_only.append(item) + if b_active: + if b_available: + b_only.append(b_item) + for item in b_iter: + b_only.append(item) + + return a_only, both, b_only + + +class FileSyncer(object): + """File synchronizer.""" + + def __init__(self, adb, local_path, remote_path, local_to_remote, + remote_to_local, preserve_times, delete_missing, allow_overwrite, + allow_replace, dry_run): + self.local = local_path + self.remote = remote_path + self.adb = AdbFileSystem(adb) + self.local_to_remote = local_to_remote + self.remote_to_local = remote_to_local + self.preserve_times = preserve_times + self.delete_missing = delete_missing + self.allow_overwrite = allow_overwrite + self.allow_replace = allow_replace + self.dry_run = dry_run + self.local_only = None + self.both = None + self.remote_only = None + self.num_bytes = 0 + self.start_time = time.time() + + def IsWorking(self): + """Tests the adb connection.""" + return self.adb.IsWorking() + + def ScanAndDiff(self): + """Scans the local and remote locations and identifies differences.""" + print('Scanning and diffing...') + locallist = BuildFileList(os, self.local) + remotelist = BuildFileList(self.adb, self.remote) + self.local_only, self.both, self.remote_only = DiffLists(locallist, + remotelist) + if not self.local_only and not self.both and not self.remote_only: + print('No files seen. User error?') + self.src_to_dst = (self.local_to_remote, self.remote_to_local) + self.dst_to_src = (self.remote_to_local, self.local_to_remote) + self.src_only = (self.local_only, self.remote_only) + self.dst_only = (self.remote_only, self.local_only) + self.src = (self.local, self.remote) + self.dst = (self.remote, self.local) + self.dst_fs = (self.adb, os) + self.push = ('Push', 'Pull') + self.copy = (self.adb.Push, self.adb.Pull) + + def InterruptProtection(self, fs, name): + """Sets up interrupt protection. + + Usage: + with self.InterruptProtection(fs, name): + DoSomething() + + If DoSomething() should get interrupted, the file 'name' will be deleted. + The exception otherwise will be passed on. + + Args: + fs: File system object. + name: File name to delete. + + Returns: + An object for use by 'with'. + """ + + dry_run = self.dry_run + + class DeleteInterruptedFile(object): + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_value, traceback): + if exc_type is not None: + print('Interrupted-%s-Delete: %s' % + ('Pull' if fs == os else 'Push', + name.decode('utf-8', errors='replace'))) + if not dry_run: + fs.unlink(name) + return False + + return DeleteInterruptedFile() + + def PerformDeletions(self): + """Perform all deleting necessary for the file sync operation.""" + if not self.delete_missing: + return + for i in [0, 1]: + if self.src_to_dst[i] and not self.dst_to_src[i]: + if not self.src_only[i] and not self.both: + print('Cowardly refusing to delete everything.') + else: + for name, s in reversed(self.dst_only[i]): + dst_name = self.dst[i] + name + print('%s-Delete: %s' % + (self.push[i], dst_name.decode('utf-8', errors='replace'))) + if stat.S_ISDIR(s.st_mode): + if not self.dry_run: + self.dst_fs[i].rmdir(dst_name) + else: + if not self.dry_run: + self.dst_fs[i].unlink(dst_name) + del self.dst_only[i][:] + + def PerformOverwrites(self): + """Delete files/directories that are in the way for overwriting.""" + src_only_prepend = ([], []) + for name, localstat, remotestat in self.both: + if stat.S_ISDIR(localstat.st_mode) and stat.S_ISDIR(remotestat.st_mode): + # A dir is a dir is a dir. + continue + elif stat.S_ISDIR(localstat.st_mode) or stat.S_ISDIR(remotestat.st_mode): + # Dir vs file? Nothing to do here yet. + pass + else: + # File vs file? Compare sizes. + if localstat.st_size == remotestat.st_size: + continue + l2r = self.local_to_remote + r2l = self.remote_to_local + if l2r and r2l: + # Truncate times to full minutes, as Android's "ls" only outputs minute + # accuracy. + localminute = int(localstat.st_mtime / 60) + remoteminute = int(remotestat.st_mtime / 60) + if localminute > remoteminute: + r2l = False + elif localminute < remoteminute: + l2r = False + if l2r and r2l: + print('Unresolvable: $%s' % name.decode('utf-8', errors='replace')) + continue + if l2r: + i = 0 # Local to remote operation. + src_stat = localstat + dst_stat = remotestat + else: + i = 1 # Remote to local operation. + src_stat = remotestat + dst_stat = localstat + dst_name = self.dst[i] + name + print('%s-Delete-Conflicting: %s' % + (self.push[i], dst_name.decode('utf-8', errors='replace'))) + if stat.S_ISDIR(localstat.st_mode) or stat.S_ISDIR(remotestat.st_mode): + if not self.allow_replace: + print('Would have to replace to do this. Use --force to allow this.') + continue + if not self.allow_overwrite: + print('Would have to overwrite to do this, which --no-clobber forbids.') + continue + if stat.S_ISDIR(dst_stat.st_mode): + kill_files = [x for x in self.dst_only[i] + if x[0][:len(name) + 1] == name + '/'] + self.dst_only[i][:] = [x for x in self.dst_only[i] + if x[0][:len(name) + 1] != name + '/'] + for l, s in reversed(kill_files): + if stat.S_ISDIR(s.st_mode): + if not self.dry_run: + self.dst_fs[i].rmdir(self.dst[i] + l) + else: + if not self.dry_run: + self.dst_fs[i].unlink(self.dst[i] + l) + if not self.dry_run: + self.dst_fs[i].rmdir(dst_name) + elif stat.S_ISDIR(src_stat.st_mode): + if not self.dry_run: + self.dst_fs[i].unlink(dst_name) + else: + if not self.dry_run: + self.dst_fs[i].unlink(dst_name) + src_only_prepend[i].append((name, src_stat)) + for i in [0, 1]: + self.src_only[i][:0] = src_only_prepend[i] + + def PerformCopies(self): + """Perform all copying necessary for the file sync operation.""" + for i in [0, 1]: + if self.src_to_dst[i]: + for name, s in self.src_only[i]: + src_name = self.src[i] + name + dst_name = self.dst[i] + name + print('%s: %s' % + (self.push[i], dst_name.decode('utf-8', errors='replace'))) + if stat.S_ISDIR(s.st_mode): + if not self.dry_run: + self.dst_fs[i].makedirs(dst_name) + else: + with self.InterruptProtection(self.dst_fs[i], dst_name): + if not self.dry_run: + self.copy[i](src_name, dst_name) + self.num_bytes += s.st_size + if not self.dry_run: + if self.preserve_times: + print('%s-Times: accessed %s, modified %s' % + (self.push[i], + time.asctime(time.localtime(s.st_atime)), + time.asctime(time.localtime(s.st_mtime)))) + self.dst_fs[i].utime(dst_name, (s.st_atime, s.st_mtime)) + + def TimeReport(self): + """Report time and amount of data transferred.""" + if self.dry_run: + print('Total: %d bytes' % self.num_bytes) + else: + end_time = time.time() + dt = end_time - self.start_time + rate = self.num_bytes / 1024.0 / dt + print('Total: %d KB/s (%d bytes in %.3fs)' % (rate, self.num_bytes, dt)) + + +def main(*args): + parser = argparse.ArgumentParser( + description='Synchronize a directory between an Android device and the '+ + 'local file system') + parser.add_argument('source', metavar='SRC', type=str, + help='The directory to read files/directories from. '+ + 'This must be a local path if -R is not specified, '+ + 'and an Android path if -R is specified.') + parser.add_argument('destination', metavar='DST', type=str, + help='The directory to write files/directories to. '+ + 'This must be an Android path if -R is not specified, '+ + 'and a local path if -R is specified.') + parser.add_argument('-e', '--adb', metavar='COMMAND', default='adb', type=str, + help='Use the given adb binary and arguments.') + parser.add_argument('-R', '--reverse', action='store_true', + help='Reverse sync (pull, not push).') + parser.add_argument('-2', '--two-way', action='store_true', + help='Two-way sync (compare modification time; after '+ + 'the sync, both sides will have all files in the '+ + 'respective newest version. This relies on the clocks '+ + 'of your system and the device to match.') + #parser.add_argument('-t', '--times', action='store_true', + # help='Preserve modification times when copying.') + parser.add_argument('-d', '--delete', action='store_true', + help='Delete files from DST that are not present on '+ + 'SRC. Mutually exclusive with -2.') + parser.add_argument('-f', '--force', action='store_true', + help='Allow deleting files/directories when having to '+ + 'replace a file by a directory or vice versa. This is '+ + 'disabled by default to prevent large scale accidents.') + parser.add_argument('-n', '--no-clobber', action='store_true', + help='Do not ever overwrite any '+ + 'existing files. Mutually exclusive with -f.') + parser.add_argument('--dry-run',action='store_true', + help='Do not do anything - just show what would '+ + 'be done.') + args = parser.parse_args() + + local = args.source + remote = args.destination + adb = args.adb + preserve_times = False # args.times + delete_missing = args.delete + allow_replace = args.force + allow_overwrite = not args.no_clobber + dry_run = args.dry_run + local_to_remote = True + remote_to_local = False + if args.two_way: + local_to_remote = True + remote_to_local = True + if args.reverse: + local_to_remote, remote_to_local = remote_to_local, local_to_remote + local, remote = remote, local + if allow_replace and not allow_overwrite: + print('--no-clobber and --force are mutually exclusive.') + parser.print_help() + return + if delete_missing and local_to_remote and remote_to_local: + print('--delete and --two-way are mutually exclusive.') + parser.print_help() + return + + syncer = FileSyncer(adb.encode('utf-8'), + local.encode('utf-8'), remote.encode('utf-8'), + local_to_remote, remote_to_local, preserve_times, + delete_missing, allow_overwrite, allow_replace, dry_run) + if not syncer.IsWorking(): + print('Device not connected or not working.') + return + try: + syncer.ScanAndDiff() + syncer.PerformDeletions() + syncer.PerformOverwrites() + syncer.PerformCopies() + finally: + syncer.TimeReport() + +if __name__ == '__main__': + main(*sys.argv)