commit 19d8d41a21db44b9a79d3b64c0fd489704119bd5 Author: g* Date: Sun May 24 15:50:03 2020 +0200 Had to restat repo. diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..621493a Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1581e58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +mobisync-log.txt +Sizakele diff --git a/adb b/adb new file mode 100755 index 0000000..63620d9 Binary files /dev/null and b/adb differ diff --git a/adb-sync b/adb-sync new file mode 100755 index 0000000..53c37c1 --- /dev/null +++ b/adb-sync @@ -0,0 +1,774 @@ +#!/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 unicode_literals +import argparse +import glob +import locale +import os +import re +import stat +import subprocess +import sys +import time + + +def _sprintf(s, *args): + # To be able to use string formatting, we first have to covert to + # unicode strings; however, we must do so in a way that preserves all + # bytes, and convert back at the end. An encoding that maps all byte + # values to different Unicode codepoints is cp437. + return (s.decode('cp437') % tuple([ + (x.decode('cp437') if type(x) == bytes else x) for x in args + ])).encode('cp437') + + +def _print(s, *args): + """Writes a binary string to stdout. + + Args: + s: The binary format string to write. + args: The args for the format string. + """ + if hasattr(sys.stdout, 'buffer'): + # Python 3. + sys.stdout.buffer.write(_sprintf(s, *args) + b'\n') + sys.stdout.buffer.flush() + else: + # Python 2. + sys.stdout.write(_sprintf(s, *args) + b'\n') + + +class AdbFileSystem(object): + """Mimics os's file interface but uses the adb utility.""" + + def __init__(self, adb): + self.stat_cache = {} + self.adb = adb + + # 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(br'''^ + (?: + (?P -) | + (?P b) | + (?P c) | + (?P d) | + (?P l) | + (?P p) | + (?P s)) + [-r][-w][-xsS] + [-r][-w][-xsS] + [-r][-w][-xtT] # Mode string. + [ ]+ + (?: + [0-9]+ # number of hard links + [ ]+ + )? + [^ ]+ # User name/ID. + [ ]+ + [^ ]+ # Group name/ID. + [ ]+ + (?(S_IFBLK) [^ ]+[ ]+[^ ]+[ ]+) # Device numbers. + (?(S_IFCHR) [^ ]+[ ]+[^ ]+[ ]+) # Device numbers. + (?(S_IFDIR) [0-9]+ [ ]+)? # directory Size. + (?(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(b'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'\\`') + arg = b'"' + 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', _sprintf(b'date +%s', + 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, caching them for later lstat calls.""" + with self.Stdout(self.adb + [b'shell', _sprintf(b'ls -al %s', + self.QuoteArgument(path + b'/'))]) as stdout: + for line in stdout: + if line.startswith(b'total '): + continue + line = line.rstrip(b'\r\n') + try: + statdata, filename = self.LsToStat(line) + except OSError: + continue + if filename is None: + _print(b'Warning: could not parse %s', line) + else: + self.stat_cache[path + b'/' + filename] = statdata + yield filename + + 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', _sprintf(b'ls -ald %s', + self.QuoteArgument(path))]) as stdout: + for line in stdout: + if line.startswith(b'total '): + continue + 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', _sprintf(b'rm %s', + self.QuoteArgument(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', _sprintf(b'rmdir %s', + self.QuoteArgument(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', _sprintf(b'mkdir -p %s', + self.QuoteArgument(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', _sprintf(b'touch -mt %s %s', + timestr, self.QuoteArgument(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',_sprintf( b'touch -at %s %s', + timestr, self.QuoteArgument(path))]) != 0: + raise OSError('touch failed') + + def glob(self, path): + with self.Stdout(self.adb + [b'shell', + _sprintf(b'for p in %s; do echo "$p"; done', + path)]) as stdout: + for line in stdout: + yield line.rstrip(b'\r\n') + + 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 + files.sort() + for n in files: + if n == b'.' or n == b'..': + continue + for t in BuildFileList(fs, path + b'/' + n, prefix + b'/' + n): + yield t + elif stat.S_ISREG(statresult.st_mode) or stat.S_ISLNK(statresult.st_mode): + yield prefix, statresult + else: + _print(b'Note: unsupported file: %s', path) + + +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 = 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(b'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(b'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 = (b'Push', b'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(b'Interrupted-%s-Delete: %s', + b'Pull' if fs == os else b'Push', name) + 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(b'Cowardly refusing to delete everything.') + else: + for name, s in reversed(self.dst_only[i]): + dst_name = self.dst[i] + name + _print(b'%s-Delete: %s', self.push[i], dst_name) + 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(b'Unresolvable: %s', name) + 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(b'%s-Delete-Conflicting: %s', self.push[i], dst_name) + if stat.S_ISDIR(localstat.st_mode) or stat.S_ISDIR(remotestat.st_mode): + if not self.allow_replace: + _print(b'Would have to replace to do this. ' + b'Use --force to allow this.') + continue + if not self.allow_overwrite: + _print(b'Would have to overwrite to do this, ' + b'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 + b'/'] + self.dst_only[i][:] = [x for x in self.dst_only[i] + if x[0][:len(name) + 1] != name + b'/'] + 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(b'%s: %s', self.push[i], dst_name) + 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(b'%s-Times: accessed %s, modified %s', + self.push[i], + time.asctime(time.localtime(s.st_atime)).encode('utf-8'), + time.asctime(time.localtime(s.st_mtime)).encode('utf-8')) + 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(b'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(b'Total: %d KB/s (%d bytes in %.3fs)', rate, self.num_bytes, dt) + + +def ExpandWildcards(globber, path): + if path.find(b'?') == -1 and path.find(b'*') == -1 and path.find(b'[') == -1: + return [path] + return globber.glob(path) + + +def FixPath(src, dst): + # rsync-like path munging to make remote specifications shorter. + append = b'' + pos = src.rfind(b'/') + if pos >= 0: + if src.endswith(b'/'): + # Final slash: copy to the destination "as is". + src = src[:-1] + else: + # No final slash: destination name == source name. + append = src[pos:] + else: + # No slash at all - use same name at destination. + append = b'/' + src + # Append the destination file name if any. + # BUT: do not append "." or ".." components! + if append != b'/.' and append != b'/..': + dst += append + return (src, dst) + + +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, nargs='+', + 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. If SRC does '+ + 'not end with a final slash, its last path component '+ + 'is appended to DST (like rsync does).') + 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('--device', action='store_true', + help='Directs command to the only connected USB device; '+ + 'returns an error if more than one USB device is '+ + 'present. '+ + 'Corresponds to the "-d" option of adb.') + parser.add_argument('--emulator', action='store_true', + help='Directs command to the only running emulator; '+ + 'returns an error if more than one emulator is running. '+ + 'Corresponds to the "-e" option of adb.') + parser.add_argument('-s', '--serial', metavar='DEVICE', type=str, + help='Directs command to the device or emulator with '+ + 'the given serial number or qualifier. Overrides '+ + 'ANDROID_SERIAL environment variable. Use "adb devices" '+ + 'to list all connected devices with their respective '+ + 'serial number. '+ + 'Corresponds to the "-s" option of adb.') + parser.add_argument('-H', '--host', metavar='HOST', type=str, + help='Name of adb server host (default: localhost). '+ + 'Corresponds to the "-H" option of adb.') + parser.add_argument('-P', '--port', metavar='PORT', type=str, + help='Port of adb server (default: 5037). '+ + 'Corresponds to the "-P" option of adb.') + 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() + args_encoding = locale.getdefaultlocale()[1] + + localpatterns = [x.encode(args_encoding) for x in args.source] + remotepath = args.destination.encode(args_encoding) + adb = args.adb.encode(args_encoding).split(b' ') + if args.device: + adb += [b'-d'] + if args.emulator: + adb += [b'-e'] + if args.serial != None: + adb += [b'-s', args.serial.encode(args_encoding)] + if args.host != None: + adb += [b'-H', args.host.encode(args_encoding)] + if args.port != None: + adb += [b'-P', args.port.encode(args_encoding)] + adb = AdbFileSystem(adb) + + # Expand wildcards. + localpaths = [] + remotepaths = [] + if args.reverse: + for pattern in localpatterns: + for src in ExpandWildcards(adb, pattern): + src, dst = FixPath(src, remotepath) + localpaths.append(src) + remotepaths.append(dst) + else: + for src in localpatterns: + src, dst = FixPath(src, remotepath) + localpaths.append(src) + remotepaths.append(dst) + + 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 + localpaths, remotepaths = remotepaths, localpaths + if allow_replace and not allow_overwrite: + _print(b'--no-clobber and --force are mutually exclusive.') + parser.print_help() + return + if delete_missing and local_to_remote and remote_to_local: + _print(b'--delete and --two-way are mutually exclusive.') + parser.print_help() + return + + # Two-way sync is only allowed with disjoint remote and local path sets. + if (remote_to_local and local_to_remote) or delete_missing: + if ((remote_to_local and len(localpaths) != len(set(localpaths))) or + (local_to_remote and len(remotepaths) != len(set(remotepaths)))): + _print(b'--two-way and --delete are only supported for disjoint sets of ' + b'source and destination paths (in other words, all SRC must ' + b'differ in basename).') + parser.print_help() + return + + for i in range(len(localpaths)): + _print(b'Sync: local %s, remote %s', localpaths[i], remotepaths[i]) + syncer = FileSyncer(adb, localpaths[i], remotepaths[i], + local_to_remote, remote_to_local, preserve_times, + delete_missing, allow_overwrite, allow_replace, dry_run) + if not syncer.IsWorking(): + _print(b'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) diff --git a/mobisync.app/Contents/Info.plist b/mobisync.app/Contents/Info.plist new file mode 100644 index 0000000..893fdb8 Binary files /dev/null and b/mobisync.app/Contents/Info.plist differ diff --git a/mobisync.app/Contents/MacOS/mobisync b/mobisync.app/Contents/MacOS/mobisync new file mode 100755 index 0000000..249a99b Binary files /dev/null and b/mobisync.app/Contents/MacOS/mobisync differ diff --git a/mobisync.app/Contents/Resources/AppIcon.icns b/mobisync.app/Contents/Resources/AppIcon.icns new file mode 100644 index 0000000..d2ff260 Binary files /dev/null and b/mobisync.app/Contents/Resources/AppIcon.icns differ diff --git a/mobisync.app/Contents/Resources/AppSettings.plist b/mobisync.app/Contents/Resources/AppSettings.plist new file mode 100644 index 0000000..6157075 Binary files /dev/null and b/mobisync.app/Contents/Resources/AppSettings.plist differ diff --git a/mobisync.app/Contents/Resources/MainMenu.nib/designable.nib b/mobisync.app/Contents/Resources/MainMenu.nib/designable.nib new file mode 100644 index 0000000..59df482 --- /dev/null +++ b/mobisync.app/Contents/Resources/MainMenu.nib/designable.nib @@ -0,0 +1,705 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobisync.app/Contents/Resources/MainMenu.nib/keyedobjects.nib b/mobisync.app/Contents/Resources/MainMenu.nib/keyedobjects.nib new file mode 100644 index 0000000..f215593 Binary files /dev/null and b/mobisync.app/Contents/Resources/MainMenu.nib/keyedobjects.nib differ diff --git a/mobisync.app/Contents/Resources/adb b/mobisync.app/Contents/Resources/adb new file mode 100755 index 0000000..63620d9 Binary files /dev/null and b/mobisync.app/Contents/Resources/adb differ diff --git a/mobisync.app/Contents/Resources/adb-sync b/mobisync.app/Contents/Resources/adb-sync new file mode 100755 index 0000000..53c37c1 --- /dev/null +++ b/mobisync.app/Contents/Resources/adb-sync @@ -0,0 +1,774 @@ +#!/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 unicode_literals +import argparse +import glob +import locale +import os +import re +import stat +import subprocess +import sys +import time + + +def _sprintf(s, *args): + # To be able to use string formatting, we first have to covert to + # unicode strings; however, we must do so in a way that preserves all + # bytes, and convert back at the end. An encoding that maps all byte + # values to different Unicode codepoints is cp437. + return (s.decode('cp437') % tuple([ + (x.decode('cp437') if type(x) == bytes else x) for x in args + ])).encode('cp437') + + +def _print(s, *args): + """Writes a binary string to stdout. + + Args: + s: The binary format string to write. + args: The args for the format string. + """ + if hasattr(sys.stdout, 'buffer'): + # Python 3. + sys.stdout.buffer.write(_sprintf(s, *args) + b'\n') + sys.stdout.buffer.flush() + else: + # Python 2. + sys.stdout.write(_sprintf(s, *args) + b'\n') + + +class AdbFileSystem(object): + """Mimics os's file interface but uses the adb utility.""" + + def __init__(self, adb): + self.stat_cache = {} + self.adb = adb + + # 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(br'''^ + (?: + (?P -) | + (?P b) | + (?P c) | + (?P d) | + (?P l) | + (?P p) | + (?P s)) + [-r][-w][-xsS] + [-r][-w][-xsS] + [-r][-w][-xtT] # Mode string. + [ ]+ + (?: + [0-9]+ # number of hard links + [ ]+ + )? + [^ ]+ # User name/ID. + [ ]+ + [^ ]+ # Group name/ID. + [ ]+ + (?(S_IFBLK) [^ ]+[ ]+[^ ]+[ ]+) # Device numbers. + (?(S_IFCHR) [^ ]+[ ]+[^ ]+[ ]+) # Device numbers. + (?(S_IFDIR) [0-9]+ [ ]+)? # directory Size. + (?(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(b'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'\\`') + arg = b'"' + 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', _sprintf(b'date +%s', + 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, caching them for later lstat calls.""" + with self.Stdout(self.adb + [b'shell', _sprintf(b'ls -al %s', + self.QuoteArgument(path + b'/'))]) as stdout: + for line in stdout: + if line.startswith(b'total '): + continue + line = line.rstrip(b'\r\n') + try: + statdata, filename = self.LsToStat(line) + except OSError: + continue + if filename is None: + _print(b'Warning: could not parse %s', line) + else: + self.stat_cache[path + b'/' + filename] = statdata + yield filename + + 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', _sprintf(b'ls -ald %s', + self.QuoteArgument(path))]) as stdout: + for line in stdout: + if line.startswith(b'total '): + continue + 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', _sprintf(b'rm %s', + self.QuoteArgument(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', _sprintf(b'rmdir %s', + self.QuoteArgument(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', _sprintf(b'mkdir -p %s', + self.QuoteArgument(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', _sprintf(b'touch -mt %s %s', + timestr, self.QuoteArgument(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',_sprintf( b'touch -at %s %s', + timestr, self.QuoteArgument(path))]) != 0: + raise OSError('touch failed') + + def glob(self, path): + with self.Stdout(self.adb + [b'shell', + _sprintf(b'for p in %s; do echo "$p"; done', + path)]) as stdout: + for line in stdout: + yield line.rstrip(b'\r\n') + + 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 + files.sort() + for n in files: + if n == b'.' or n == b'..': + continue + for t in BuildFileList(fs, path + b'/' + n, prefix + b'/' + n): + yield t + elif stat.S_ISREG(statresult.st_mode) or stat.S_ISLNK(statresult.st_mode): + yield prefix, statresult + else: + _print(b'Note: unsupported file: %s', path) + + +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 = 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(b'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(b'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 = (b'Push', b'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(b'Interrupted-%s-Delete: %s', + b'Pull' if fs == os else b'Push', name) + 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(b'Cowardly refusing to delete everything.') + else: + for name, s in reversed(self.dst_only[i]): + dst_name = self.dst[i] + name + _print(b'%s-Delete: %s', self.push[i], dst_name) + 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(b'Unresolvable: %s', name) + 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(b'%s-Delete-Conflicting: %s', self.push[i], dst_name) + if stat.S_ISDIR(localstat.st_mode) or stat.S_ISDIR(remotestat.st_mode): + if not self.allow_replace: + _print(b'Would have to replace to do this. ' + b'Use --force to allow this.') + continue + if not self.allow_overwrite: + _print(b'Would have to overwrite to do this, ' + b'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 + b'/'] + self.dst_only[i][:] = [x for x in self.dst_only[i] + if x[0][:len(name) + 1] != name + b'/'] + 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(b'%s: %s', self.push[i], dst_name) + 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(b'%s-Times: accessed %s, modified %s', + self.push[i], + time.asctime(time.localtime(s.st_atime)).encode('utf-8'), + time.asctime(time.localtime(s.st_mtime)).encode('utf-8')) + 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(b'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(b'Total: %d KB/s (%d bytes in %.3fs)', rate, self.num_bytes, dt) + + +def ExpandWildcards(globber, path): + if path.find(b'?') == -1 and path.find(b'*') == -1 and path.find(b'[') == -1: + return [path] + return globber.glob(path) + + +def FixPath(src, dst): + # rsync-like path munging to make remote specifications shorter. + append = b'' + pos = src.rfind(b'/') + if pos >= 0: + if src.endswith(b'/'): + # Final slash: copy to the destination "as is". + src = src[:-1] + else: + # No final slash: destination name == source name. + append = src[pos:] + else: + # No slash at all - use same name at destination. + append = b'/' + src + # Append the destination file name if any. + # BUT: do not append "." or ".." components! + if append != b'/.' and append != b'/..': + dst += append + return (src, dst) + + +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, nargs='+', + 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. If SRC does '+ + 'not end with a final slash, its last path component '+ + 'is appended to DST (like rsync does).') + 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('--device', action='store_true', + help='Directs command to the only connected USB device; '+ + 'returns an error if more than one USB device is '+ + 'present. '+ + 'Corresponds to the "-d" option of adb.') + parser.add_argument('--emulator', action='store_true', + help='Directs command to the only running emulator; '+ + 'returns an error if more than one emulator is running. '+ + 'Corresponds to the "-e" option of adb.') + parser.add_argument('-s', '--serial', metavar='DEVICE', type=str, + help='Directs command to the device or emulator with '+ + 'the given serial number or qualifier. Overrides '+ + 'ANDROID_SERIAL environment variable. Use "adb devices" '+ + 'to list all connected devices with their respective '+ + 'serial number. '+ + 'Corresponds to the "-s" option of adb.') + parser.add_argument('-H', '--host', metavar='HOST', type=str, + help='Name of adb server host (default: localhost). '+ + 'Corresponds to the "-H" option of adb.') + parser.add_argument('-P', '--port', metavar='PORT', type=str, + help='Port of adb server (default: 5037). '+ + 'Corresponds to the "-P" option of adb.') + 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() + args_encoding = locale.getdefaultlocale()[1] + + localpatterns = [x.encode(args_encoding) for x in args.source] + remotepath = args.destination.encode(args_encoding) + adb = args.adb.encode(args_encoding).split(b' ') + if args.device: + adb += [b'-d'] + if args.emulator: + adb += [b'-e'] + if args.serial != None: + adb += [b'-s', args.serial.encode(args_encoding)] + if args.host != None: + adb += [b'-H', args.host.encode(args_encoding)] + if args.port != None: + adb += [b'-P', args.port.encode(args_encoding)] + adb = AdbFileSystem(adb) + + # Expand wildcards. + localpaths = [] + remotepaths = [] + if args.reverse: + for pattern in localpatterns: + for src in ExpandWildcards(adb, pattern): + src, dst = FixPath(src, remotepath) + localpaths.append(src) + remotepaths.append(dst) + else: + for src in localpatterns: + src, dst = FixPath(src, remotepath) + localpaths.append(src) + remotepaths.append(dst) + + 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 + localpaths, remotepaths = remotepaths, localpaths + if allow_replace and not allow_overwrite: + _print(b'--no-clobber and --force are mutually exclusive.') + parser.print_help() + return + if delete_missing and local_to_remote and remote_to_local: + _print(b'--delete and --two-way are mutually exclusive.') + parser.print_help() + return + + # Two-way sync is only allowed with disjoint remote and local path sets. + if (remote_to_local and local_to_remote) or delete_missing: + if ((remote_to_local and len(localpaths) != len(set(localpaths))) or + (local_to_remote and len(remotepaths) != len(set(remotepaths)))): + _print(b'--two-way and --delete are only supported for disjoint sets of ' + b'source and destination paths (in other words, all SRC must ' + b'differ in basename).') + parser.print_help() + return + + for i in range(len(localpaths)): + _print(b'Sync: local %s, remote %s', localpaths[i], remotepaths[i]) + syncer = FileSyncer(adb, localpaths[i], remotepaths[i], + local_to_remote, remote_to_local, preserve_times, + delete_missing, allow_overwrite, allow_replace, dry_run) + if not syncer.IsWorking(): + _print(b'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) diff --git a/mobisync.app/Contents/Resources/script b/mobisync.app/Contents/Resources/script new file mode 100755 index 0000000..0706687 --- /dev/null +++ b/mobisync.app/Contents/Resources/script @@ -0,0 +1,112 @@ +#!/bin/zsh +# This script syncs an Android with a Mac using adb and adb-sync. +# Version 9.5 (16 April 2019) + +# The below are Platypus features for managing UI +echo "DETAILS:HIDE" # Hides the deatiled text in progress bar +echo "PROGRESS:0" # Show the progress bar at 0% +echo "Script starting" # Show this message above the progress bar + +# Export paths for use if the script is turned into an app using Platypus. +export LC_ALL=en_US.UTF-8 +export LANG=en_US.UTF-8 + +# Set the path to the log file and destinations +export LOG=~/Projects/Programming/push/mobisync/mobisync-log.txt # A tilde will not work within quote marks. +export MEDIA=/sdcard/DCIM/Moment/ +export DOCUMENTS=/sdcard/Documents/ +export DOWNLOAD=/sdcard/Download/ +export DOWNLOADS=~/Downloads/ +export MAC_MUSIC=~/Music/ +export ANDROID_MUSIC=/sdcard/Music/ +export OCTOPUS=~/Documents/tentacles/ +export TENTACLES=/sdcard/.tentacles/ +export APPS=~/Backup/Apps/ +export RECOVERY=~/Backup/Recovery/ +export MOBILE=~/Backup/Mobile/ +export SIGNAL=~/Backup/Signal/ +export AUDIO=/sdcard/Record/ + +echo "NOTIFICATION:Syncing is starting..." # Send a notification (with logo) +echo "PROGRESS:10" +echo "Default paths set" + +# Function: Reviews the last command for errors. Then prints update complete to log or shows error dialog. Takes section variable. +catcher () { +if [ "$?" = "0" ]; then + printf "$1 synced." >> $LOG # If no error, print sync complete to file. + echo "" >> $LOG # Add a line to file. +else # If error, show a dialog stating the section where the error occured. + echo "NOTIFICATION:'$1' sync failed." + printf "$1 failed to sync." >> $LOG # If error, print sync failed to file. + echo "" >> $LOG # Add a line to file. +fi +} + +# Function: Creates a horizontal line in the text file. +line () { +printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' - >> $LOG # Prints line +} + +# Writes the header for the log file: Program, Version number, Date and Line. +{ echo "mobisync 9.5"; echo "Log: " `date`; line; } > $LOG + +# Syncing images / video on device to the Downloads folder and sync the wallpaper on computer. +{ adb-sync --reverse $MEDIA $DOWNLOADS; } >> $LOG +catcher Images +line +echo "PROGRESS:20" +echo "Images synced" + +# Syncing audio on device to the Downloads folder on computer. Audio is isolated to extension-type downloads only. +{ adb-sync --reverse $AUDIO $DOWNLOADS; } >> $LOG +catcher Audio +line +echo "PROGRESS:30" +echo "Media synced" + +# Syncing documents on device to the Downloads folder on computer. +{ adb-sync --reverse $DOCUMENTS $DOWNLOADS; adb-sync --reverse $DOWNLOAD $DOWNLOADS; } >> $LOG +catcher Documents +line +echo "PROGRESS:35" + +# Syncing qSelf data on device to the Documents/qSelf folder on computer. +{ adb-sync --reverse $TENTACLES $OCTOPUS; } >> $LOG +catcher qSelf +line +echo "PROGRESS:40" +echo "Documents synced" + +# Syncing music on computer to the music on phone. +adb-sync --delete $MAC_MUSIC $ANDROID_MUSIC >> $LOG +catcher Music +line +echo "PROGRESS:50" +echo "Music synced to phone" + +# Syncing app, recovery and TWRP backups. +{ adb-sync --reverse --delete /sdcard/Backup/Apps/ $APPS; adb-sync --reverse /sdcard/.Ota/ $RECOVERY; adb-sync --reverse --delete /sdcard/TWRP/Backups/75fbabd4/ $MOBILE; adb-sync --reverse --delete /sdcard/Signal/Backups/ $SIGNAL; } >> $LOG +catcher Backups +line +echo "PROGRESS:70" +echo "Mobile backups synced" + +# Syncing system backups and recovery to phone. +{ adb-sync --delete ~/Documents/Secure.sparsebundle /sdcard/Backup/System/; adb-sync --delete $RECOVERY /sdcard/Backup/Recovery/; } >> $LOG +catcher System +line +echo "PROGRESS:80" +echo "System backups synced" + +# Deleting all synced media from phone. +{ adb shell rm -rf '/sdcard/Record/*.mp3'; adb shell rm -rf '/sdcard/Movies/*'; adb shell rm -rf '/sdcard/DJI/Camera/*'; adb shell rm -rf '/sdcard/DCIM/*'; adb shell rm -rf '/sdcard/DCIM/.thumbnails/*'; adb shell rm -rf '/sdcard/Documents/*'; adb shell rm -rf '/sdcard/Download/*' } >> $LOG +echo "PROGRESS:90" +echo "Cleanup completed" + +# Notification that the sync is over. +printf "Syncing is complete. END" >> $LOG +echo "PROGRESS:100" +echo "NOTIFICATION:Syncing is complete" + +echo "QUITAPP" diff --git a/mobisync.sh b/mobisync.sh new file mode 100755 index 0000000..0433ff3 --- /dev/null +++ b/mobisync.sh @@ -0,0 +1,88 @@ +#!/bin/zsh +# This script syncs an Android with a Mac using adb and adb-sync. +# Version 11.2.1 (23 April 2020) +# Fixed System backup and Pictures folder download. + +# Export paths for use if the script is turned into an app using Platypus. +export LC_ALL=en_US.UTF-8 +export LANG=en_US.UTF-8 +export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/$" + +# Sets the local paths +export LOG=~/Projects/Programming/push/mobisync/mobisync-log.txt +export DOWNLOADS=~/Downloads/ +export MOBILE=~/Mobile/ +export SIGNAL=~/Mobile/Signal/ +export TENTACLES=~/Documents/.tentacles/ +export MUSIC=~/Music/ +export SYSTEM=/Volumes/Archive\ 01/System/ +export TRAVEL=~/Documents/Travel/ + +echo "NOTIFICATION: Sync is starting..." # Send a notification (with logo) + +# Function: Reviews the last command for errors. Then prints update complete to log or shows error dialog. Takes section variable. +catcher () { +if [ "$?" = "0" ]; then + printf "$1 synced." >> $LOG # If no error, print sync complete to file. + echo "" >> $LOG # Add a line to file. +else # If error, show a dialog stating the section where the error occured. + echo "NOTIFICATION: '$1' sync failed." + printf "$1 failed to sync." >> $LOG # If error, print sync failed to file. + echo "" >> $LOG # Add a line to file. + ERROR=1 # Sets variable for error in script to 1. +fi +} + +# Function: If there has been an error in the script open the log file. +verify () { +if [ $ERROR = 1 ]; then + open $LOG + echo "NOTIFICATION: Syncing completed with some errors." +else + echo "NOTIFICATION: Syncing is complete" +fi +} + +# Function: Creates a horizontal line in the text file. +line () { +printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' - >> $LOG # Prints line +} + +# Writes the header for the log file: Program, Version number, Date and Line. +{ echo "mobisync 11.2"; echo "Log: " `date`; line; } > $LOG + +# Syncing images and video on device to the Downloads folder and sync the wallpaper on computer. +{ adb shell find "/sdcard/DCIM/Camera/" -iname "*.mp4" | tr -d '\015' | while read line; do adb pull "$line" $DOWNLOADS; done; adb shell find "/sdcard/DCIM/Camera/" -iname "*.jpg" | tr -d '\015' | while read line; do adb pull "$line" $DOWNLOADS; done; adb shell find "/sdcard/DCIM/" -iname "*.jpg" | tr -d '\015' | while read line; do adb pull "$line" $DOWNLOADS; done; adb shell find "/sdcard/Pictures/" -iname "*.jpg" | tr -d '\015' | while read line; do adb pull "$line" $DOWNLOADS; done; } >> $LOG +catcher Images +line + +# Syncing audio on device to the Downloads folder on computer and Music from the computer. +{ adb shell find "/sdcard/Recordings/" -iname "*.mp3" | tr -d '\015' | while read line; do adb pull "$line" $DOWNLOADS; done; adb-sync --delete $MUSIC "/sdcard/Music/" } >> $LOG +catcher Audio +line + +# Syncing documents on device to the Downloads folder on computer. +{ adb-sync --reverse $DOWNLOADS "/sdcard/Documents/"; adb-sync --reverse $DOWNLOADS "/sdcard/Download/"; adb shell rm -rf '/sdcard/Documents/*'; adb-sync --delete $TRAVEL "/sdcard/Documents/"; } >> $LOG +catcher Documents +line + +# Syncing qSelf data on device to the Documents/qSelf folder on computer. +{ adb-sync --reverse --delete "/sdcard/.tentacles/" $TENTACLES; } >> $LOG +catcher Tentacles +line + +# Syncing app, recovery and TWRP backups. +{ adb-sync --reverse --delete "/sdcard/Mobile/" $MOBILE; adb-sync --reverse --delete "/sdcard/Signal/Backups/" $SIGNAL; adb-sync --delete $SYSTEM "/sdcard/System/"; } >> $LOG +catcher Backups +line + +# Deleting all synced media from phone. +{ adb shell rm -rf '/sdcard/Recordings/*.mp3'; adb shell rm -rf '/sdcard/Movies/*'; adb shell rm -rf '/sdcard/DCIM/*'; adb shell rm -rf '/sdcard/Pictures/*'; adb shell rm -rf '/sdcard/Download/*'; adb shell rm -rf '/sdcard/Downloads/*' } >> $LOG +catcher Delete +line + +# Notification that the sync is over. +printf "Syncing is complete. END" >> $LOG +verify $ERROR + +echo "QUITAPP" \ No newline at end of file diff --git a/octopus-logo-2500x2500.afdesign b/octopus-logo-2500x2500.afdesign new file mode 100644 index 0000000..9dd1ffd Binary files /dev/null and b/octopus-logo-2500x2500.afdesign differ diff --git a/octopus-logo-512x512.png b/octopus-logo-512x512.png new file mode 100644 index 0000000..3c9340c Binary files /dev/null and b/octopus-logo-512x512.png differ diff --git a/octopus-logo-purple-2500x2500.afphoto b/octopus-logo-purple-2500x2500.afphoto new file mode 100644 index 0000000..029f239 Binary files /dev/null and b/octopus-logo-purple-2500x2500.afphoto differ diff --git a/octopus-logo-purple-2500x2500.png b/octopus-logo-purple-2500x2500.png new file mode 100644 index 0000000..7d9276f Binary files /dev/null and b/octopus-logo-purple-2500x2500.png differ diff --git a/octopus-logo.icns b/octopus-logo.icns new file mode 100644 index 0000000..d2ff260 Binary files /dev/null and b/octopus-logo.icns differ diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..0e4cb21 --- /dev/null +++ b/readme.md @@ -0,0 +1,14 @@ +# mobisync + +This script syncs an Android with a Mac using adb and adb-sync. You will need to edit the script to meet your own needs. + +## Dependencies + +**adb** +*brew install android-platform-tools* + +**adb-sync** +*git clone https://github.com/google/adb-sync.git* + +## Note +Some knowledge of adb and the Android file system is required. Runs on macOS.